Commit 716ba3cf authored by Lei Shi's avatar Lei Shi Committed by Chromium LUCI CQ

Add Pause and Resume in Select to Speak

This CL partially implements the Pause and Resume feature in STS. It can only pause or resume at a user-selected region, without automatically continuing reading outside user-selected content. I will add the auto-continue feature and tests in a follow up CL.

To enable this feature:
1. When pause triggered, we simply stop the current TTS. When resume triggered, we use startCurrentNodeGroup_ to read from the current index.
2. Refactored the update functions of floating panel. Creating one function (i.e.,updateNavigationPanel_) that updates the visibility and position of the panel.

AX-Relnotes: When #select-to-speak-navigation-control chrome://flag enabled, when Select-to-speak is activated and speaking, pressing pause will pause the audio and resume will continue from the last stop position.

Bug: 1143817
Change-Id: I4b28dfdfe1724e2b7cf138587d0b8818b7172966
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2558981
Commit-Queue: Lei Shi <leileilei@google.com>
Reviewed-by: default avatarDominic Mazzoni <dmazzoni@chromium.org>
Reviewed-by: default avatarAkihiro Ota <akihiroota@chromium.org>
Cr-Commit-Position: refs/heads/master@{#834423}
parent 975ee017
...@@ -60,6 +60,17 @@ MockTts.prototype = { ...@@ -60,6 +60,17 @@ MockTts.prototype = {
this.options_.onEvent({type: 'end'}); this.options_.onEvent({type: 'end'});
} }
}, },
/**
* Mock the speaking process of TTS, and simulate a word end event.
* @param {number} nextStartIndex The start char index of the word to be
* spoken.
*/
speakUntilCharIndex(nextStartIndex) {
this.currentlySpeaking_ = true;
if (this.options_) {
this.options_.onEvent({type: 'word', charIndex: nextStartIndex});
}
},
stop() { stop() {
this.pendingUtterances_ = []; this.pendingUtterances_ = [];
this.currentlySpeaking_ = false; this.currentlySpeaking_ = false;
......
...@@ -66,6 +66,21 @@ class SelectToSpeak { ...@@ -66,6 +66,21 @@ class SelectToSpeak {
*/ */
this.state_ = SelectToSpeakState.INACTIVE; this.state_ = SelectToSpeakState.INACTIVE;
/**
* Whether the TTS is on pause. When |this.state_| is
* SelectToSpeakState.SPEAKING, |this.paused_| indicates whether we are
* putting TTS on hold.
* TODO(leileilei): use SelectToSpeakState.PAUSE to indicate the status.
* @private {boolean}
*/
this.ttsPaused_ = false;
/**
* Function to be called when STS finishes a pausing request.
* @private {?function()}
*/
this.pauseCompleteCallback_ = null;
/** @type {InputHandler} */ /** @type {InputHandler} */
this.inputHandler_ = null; this.inputHandler_ = null;
...@@ -165,6 +180,14 @@ class SelectToSpeak { ...@@ -165,6 +180,14 @@ class SelectToSpeak {
currentEndCharIndex: undefined, currentEndCharIndex: undefined,
}; };
/**
* The position of the current focus ring, which usually highlights the
* entire paragraph. Keep this as a member variable so that the control
* panel can be updated easily.
* @private {!Array<!chrome.accessibilityPrivate.ScreenRect>}
*/
this.currentFocusRing_ = [];
/** @private {?AutomationNode} */ /** @private {?AutomationNode} */
this.currentBlockParent_ = null; this.currentBlockParent_ = null;
...@@ -556,6 +579,62 @@ class SelectToSpeak { ...@@ -556,6 +579,62 @@ class SelectToSpeak {
} }
} }
/**
* Whether the STS is on a pause state, where |this.ttsPaused_| is true and
* |this.state_| is SPEAKING.
* @private
* TODO(leileilei): use SelectToSpeakState.PAUSE to indicate the status.
*/
isPaused_() {
return this.ttsPaused_ && this.state_ === SelectToSpeakState.SPEAKING;
}
/**
* Set |this.ttsPaused_| and |this.state_| according to pause status.
* @param {boolean} shouldPause whether the TTS is on pause or speaking.
* @private
* TODO(leileilei): use SelectToSpeakState.PAUSE to indicate the status.
*/
updatePauseStatusFromTtsEvent_(shouldPause) {
this.ttsPaused_ = shouldPause;
this.onStateChanged_(SelectToSpeakState.SPEAKING);
if (shouldPause && this.pauseCompleteCallback_) {
this.pauseCompleteCallback_();
}
}
/**
* Pause the TTS. We do not assert isPaused_() before stopping TTS in case
* |this.ttsPaused_| was true while tts is speaking. This function also sets
* the |this.pauseCompleteCallback_|, which will be executed at the end of
* the pause process in |updatePauseStatusFromTtsEvent_|. This enables us to
* execute functions when the pause request is finished. For example, we can
* adjust |this.navigationState_| after a pause is fulfilled, and then resume
* to navigate to different positions.
* @return {!Promise}
* @private
*/
pause_() {
return new Promise((resolve) => {
this.pauseCompleteCallback_ = () => {
this.pauseCompleteCallback_ = null;
resolve();
};
chrome.tts.stop();
});
}
/**
* Resume the TTS. Currently, we just ask the TTS engine to pick up where it
* quited last time in |this.startCurrentNodeGroup_|.
* @private
*/
resume_() {
if (this.isPaused_()) {
this.startCurrentNodeGroup_();
}
}
/** /**
* Stop speech. If speech was in-progress, the interruption * Stop speech. If speech was in-progress, the interruption
* event will be caught and clearFocusRingAndNode_ will be * event will be caught and clearFocusRingAndNode_ will be
...@@ -602,6 +681,25 @@ class SelectToSpeak { ...@@ -602,6 +681,25 @@ class SelectToSpeak {
this.scrollToSpokenNode_ = false; this.scrollToSpokenNode_ = false;
} }
/**
* Update the navigation floating panel.
* @private
*/
updateNavigationPanel_() {
if (this.shouldShowNavigationControls_() && this.currentFocusRing_.length) {
// If the feature is enabled and we have a valid focus ring, flip the
// pause and resume button according to the current STS and TTS state.
// Also, update the location of the panel according to the focus ring.
chrome.accessibilityPrivate.updateSelectToSpeakPanel(
/* show= */ true, /* anchor= */ this.currentFocusRing_[0],
/* isPaused= */ this.isPaused_(), /* speed= */ 1.2);
} else {
// Dismiss the panel if either the feature is disabled or the focus ring
// is not valid.
chrome.accessibilityPrivate.updateSelectToSpeakPanel(/* show= */ false);
}
}
/** /**
* Clears the focus ring, but does not clear the current * Clears the focus ring, but does not clear the current
* node. * node.
...@@ -611,9 +709,7 @@ class SelectToSpeak { ...@@ -611,9 +709,7 @@ class SelectToSpeak {
this.setFocusRings_([], false /* do not draw background */); this.setFocusRings_([], false /* do not draw background */);
chrome.accessibilityPrivate.setHighlights( chrome.accessibilityPrivate.setHighlights(
[], this.prefsManager_.highlightColor()); [], this.prefsManager_.highlightColor());
if (this.shouldShowNavigationControls_()) { this.updateNavigationPanel_();
chrome.accessibilityPrivate.updateSelectToSpeakPanel(/* show= */ false);
}
} }
/** /**
...@@ -624,6 +720,7 @@ class SelectToSpeak { ...@@ -624,6 +720,7 @@ class SelectToSpeak {
* @private * @private
*/ */
setFocusRings_(rects, drawBackground) { setFocusRings_(rects, drawBackground) {
this.currentFocusRing_ = rects;
let color = '#0000'; // Fully transparent. let color = '#0000'; // Fully transparent.
if (drawBackground && this.prefsManager_.backgroundShadingEnabled()) { if (drawBackground && this.prefsManager_.backgroundShadingEnabled()) {
color = DEFAULT_BACKGROUND_SHADING_COLOR; color = DEFAULT_BACKGROUND_SHADING_COLOR;
...@@ -766,6 +863,12 @@ class SelectToSpeak { ...@@ -766,6 +863,12 @@ class SelectToSpeak {
case SelectToSpeakPanelAction.EXIT: case SelectToSpeakPanelAction.EXIT:
this.stopAll_(); this.stopAll_();
break; break;
case SelectToSpeakPanelAction.PAUSE:
this.pause_();
break;
case SelectToSpeakPanelAction.RESUME:
this.resume_();
break;
default: default:
// TODO(crbug.com/1140216): Implement other actions. // TODO(crbug.com/1140216): Implement other actions.
} }
...@@ -962,7 +1065,7 @@ class SelectToSpeak { ...@@ -962,7 +1065,7 @@ class SelectToSpeak {
options.onEvent = (event) => { options.onEvent = (event) => {
if (event.type === 'start' && nodeGroup.nodes.length > 0) { if (event.type === 'start' && nodeGroup.nodes.length > 0) {
this.onStateChanged_(SelectToSpeakState.SPEAKING); this.updatePauseStatusFromTtsEvent_(false /* shouldPause */);
this.currentBlockParent_ = nodeGroup.blockParent; this.currentBlockParent_ = nodeGroup.blockParent;
// Update |currentCharIndex|. Find the first non-space char index in // Update |currentCharIndex|. Find the first non-space char index in
...@@ -989,9 +1092,16 @@ class SelectToSpeak { ...@@ -989,9 +1092,16 @@ class SelectToSpeak {
this.testCurrentNode_(); this.testCurrentNode_();
} }
} else if (event.type === 'interrupted' || event.type === 'cancelled') { } else if (event.type === 'interrupted' || event.type === 'cancelled') {
if (!this.shouldShowNavigationControls_()) { if (!this.shouldShowNavigationControls_() ||
// Auto dismiss when navigation control is not enabled. !this.pauseCompleteCallback_) {
// Auto dismiss when navigation control is not enabled. In addition,
// if the interrupted or cancelled events are not triggered by
// |this.pause_| (e.g., from stopAll_), we should leave STS as
// INACTIVE. Currently, we check |this.pauseCompleteCallback_| as a
// proxy to see if the interrupted events are from |this.pause_|.
this.onStateChanged_(SelectToSpeakState.INACTIVE); this.onStateChanged_(SelectToSpeakState.INACTIVE);
} else {
this.updatePauseStatusFromTtsEvent_(true /* shouldPause */);
} }
} else if (event.type === 'end') { } else if (event.type === 'end') {
this.startNodeGroupAfter_( this.startNodeGroupAfter_(
...@@ -1166,6 +1276,9 @@ class SelectToSpeak { ...@@ -1166,6 +1276,9 @@ class SelectToSpeak {
} }
} else { } else {
this.currentNodeWord_ = null; this.currentNodeWord_ = null;
// There are many cases where we won't update the node highlight or test
// the node. Thus, we need to update the panel independently.
this.updateNavigationPanel_();
} }
} }
...@@ -1318,14 +1431,7 @@ class SelectToSpeak { ...@@ -1318,14 +1431,7 @@ class SelectToSpeak {
focusRingRect = node.location; focusRingRect = node.location;
} }
this.setFocusRings_([focusRingRect], true /* draw background */); this.setFocusRings_([focusRingRect], true /* draw background */);
if (this.shouldShowNavigationControls_()) { this.updateNavigationPanel_();
// TODO(crbug.com/1143817): Update paused state correctly once
// pause/resume functionality is implemented.
// TODO(crbug.com/): Set actual initial speed.
chrome.accessibilityPrivate.updateSelectToSpeakPanel(
/* show= */ true, /* anchor= */ focusRingRect, /* isPaused= */ false,
/* speed= */ 1.2);
}
} }
/** /**
......
...@@ -141,3 +141,34 @@ TEST_F( ...@@ -141,3 +141,34 @@ TEST_F(
this.triggerReadMouseSelectedText(event1, event1); this.triggerReadMouseSelectedText(event1, event1);
}); });
}); });
TEST_F('SelectToSpeakNavigationControlTest', 'PauseAndResume', function() {
const bodyHtml = `
<p id="p1">Paragraph 1</p>'
`;
this.runWithLoadedTree(
this.generateHtmlWithSelectedElement('p1', bodyHtml), () => {
this.triggerReadSelectedText();
// Speaks the first word.
this.mockTts.speakUntilCharIndex(10);
assertTrue(this.mockTts.currentlySpeaking());
assertEquals(this.mockTts.pendingUtterances().length, 1);
this.assertEqualsCollapseWhitespace(
this.mockTts.pendingUtterances()[0], 'Paragraph 1');
// Hitting pause will stop the current TTS.
selectToSpeak.onSelectToSpeakPanelAction_(
chrome.accessibilityPrivate.SelectToSpeakPanelAction.PAUSE);
assertFalse(this.mockTts.currentlySpeaking());
assertEquals(this.mockTts.pendingUtterances().length, 0);
// Hitting resume will start from the next position.
selectToSpeak.onSelectToSpeakPanelAction_(
chrome.accessibilityPrivate.SelectToSpeakPanelAction.RESUME);
assertTrue(this.mockTts.currentlySpeaking());
assertEquals(this.mockTts.pendingUtterances().length, 1);
this.assertEqualsCollapseWhitespace(
this.mockTts.pendingUtterances()[0], '1');
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment