1- // Register .NET object for interop
1+ const synth = window . speechSynthesis ;
2+
3+ let voices = [ ] ;
4+
5+ const voicesReady = new Promise ( ( resolve ) => {
6+ const tryGet = ( ) => {
7+ const voice = synth . getVoices ( ) ;
8+ if ( voice && voice . length ) {
9+ voices = voice ;
10+ resolve ( voice ) ;
11+ return true ;
12+ }
13+ return false ;
14+ } ;
15+ if ( tryGet ( ) ) return ;
16+ const onVoices = ( ) => {
17+ if ( tryGet ( ) ) {
18+ synth . removeEventListener ( 'voiceschanged' , onVoices ) ;
19+ }
20+ } ;
21+ synth . addEventListener ( 'voiceschanged' , onVoices ) ;
22+ // Fallback polling for browsers that don't fire voiceschanged reliably
23+ let tries = 0 ;
24+ const poll = setInterval ( ( ) => {
25+ if ( tryGet ( ) || ++ tries > 30 ) {
26+ clearInterval ( poll ) ;
27+ voices = synth . getVoices ( ) || [ ] ;
28+ synth . removeEventListener ( 'voiceschanged' , onVoices ) ;
29+ resolve ( voices ) ;
30+ }
31+ } , 100 ) ;
32+ } ) ;
33+
34+ // iOS/iPadOS Safari requires a user gesture before speech works reliably.
35+ // This one-time unlock speaks a silent utterance on first tap/click/keydown.
36+ let __ttsUnlocked = false ;
37+ let __unlockPromise = null ;
38+ function ensureTtsUnlocked ( ) {
39+ if ( __ttsUnlocked ) return Promise . resolve ( ) ;
40+ if ( __unlockPromise ) return __unlockPromise ;
41+ __unlockPromise = new Promise ( ( resolve ) => {
42+ const cleanup = ( ) => {
43+ [ 'click' , 'touchstart' , 'keydown' ] . forEach ( evt => document . removeEventListener ( evt , onEvent , true ) ) ;
44+ } ;
45+ const onEvent = ( ) => {
46+ try {
47+ const u = new SpeechSynthesisUtterance ( '' ) ; // silent token
48+ u . volume = 0 ;
49+ u . rate = 1 ;
50+ u . onend = ( ) => {
51+ __ttsUnlocked = true ;
52+ cleanup ( ) ;
53+ resolve ( ) ;
54+ } ;
55+ // Queue in a macrotask to avoid race with gesture handling
56+ setTimeout ( ( ) => synth . speak ( u ) , 0 ) ;
57+ } catch ( _ ) {
58+ __ttsUnlocked = true ;
59+ cleanup ( ) ;
60+ resolve ( ) ;
61+ }
62+ } ;
63+ [ 'click' , 'touchstart' , 'keydown' ] . forEach ( evt => document . addEventListener ( evt , onEvent , true ) ) ;
64+ } ) ;
65+ return __unlockPromise ;
66+ }
67+
68+ function populateVoiceList ( ) {
69+ voices = synth . getVoices ( ) . sort ( function ( a , b ) {
70+ const aname = a . name . toUpperCase ( ) ;
71+ const bname = b . name . toUpperCase ( ) ;
72+
73+ if ( aname < bname ) {
74+ return - 1 ;
75+ } else if ( aname == bname ) {
76+ return 0 ;
77+ } else {
78+ return + 1 ;
79+ }
80+ } ) ;
81+ }
82+
83+ async function speakFromControls ( input ) {
84+ // iOS/iPadOS: require user-gesture unlock and stable voice list
85+ await voicesReady ;
86+
87+ const t = ( typeof input === 'string' ? input . trim ( ) : ( input ?. value || '' ) . trim ( ) ) ;
88+ if ( ! t ) return ;
89+
90+ // Cancel any current speech to avoid overlaps/refresh quirks
91+ if ( synth . speaking ) {
92+ synth . cancel ( ) ;
93+ }
94+
95+ const utterThis = new SpeechSynthesisUtterance ( t ) ;
96+
97+ utterThis . onend = function ( ) {
98+ console . log ( "SpeechSynthesisUtterance.onend" ) ;
99+ } ;
100+
101+ utterThis . onerror = function ( e ) {
102+ console . error ( "SpeechSynthesisUtterance.onerror" , e ) ;
103+ } ;
104+
105+ const available = speechSynthesis . getVoices ( ) ;
106+ let voice = null ;
107+ voice = available . find ( v => v . default ) || available [ 0 ] ;
108+ if ( voice ) {
109+ utterThis . voice = voice ;
110+ if ( voice . lang ) utterThis . lang = voice . lang ; // Safari iOS respects lang better
111+ }
112+
113+ utterThis . pitch = 1 ;
114+ utterThis . rate = 1 ;
115+
116+ // Safari sometimes needs a microtask delay after cancel before speak
117+ setTimeout ( ( ) => synth . speak ( utterThis ) , 0 ) ;
118+ }
119+
120+ async function initUi ( ) {
121+ await voicesReady ;
122+ // Populate voices now that controls exist
123+ populateVoiceList ( ) ;
124+ if ( speechSynthesis . onvoiceschanged !== undefined ) {
125+ speechSynthesis . onvoiceschanged = populateVoiceList ;
126+ }
127+ return true ;
128+ }
129+
130+ // Initialize when DOM is ready; also handle Blazor re-renders
131+ document . addEventListener ( 'DOMContentLoaded' , async ( ) => {
132+ // Set up iOS unlock listeners early
133+ ensureTtsUnlocked ( ) ;
134+ if ( await initUi ( ) ) return ;
135+ const obs = new MutationObserver ( async ( ) => {
136+ if ( await initUi ( ) ) obs . disconnect ( ) ;
137+ } ) ;
138+ obs . observe ( document . body , { childList : true , subtree : true } ) ;
139+ } ) ;
140+
141+ // Expose a helper to manually unlock from .NET or UI (tap/click)
142+ window . unlockTtsForIOS = ( ) => ensureTtsUnlocked ( ) ;
143+
144+ // Register .NET object for interop
2145 function registerDotNetObject ( dotNetObj ) {
3- window . myDotNetObj = dotNetObj ;
146+ window . myDotNetObj = dotNetObj ;
147+ ensureTtsUnlocked ( ) ;
4148}
5149// Read selected text and highlight
6150function readSelectedText ( args , zoomLevel , muteVoice ) {
@@ -15,7 +159,7 @@ function readSelectedText(args, zoomLevel, muteVoice) {
15159 } ) ;
16160 if ( muteVoice ) return ;
17161 requestAnimationFrame ( ( ) => {
18- speakText ( text , clearAllHighlights ) ;
162+ speakFromControls ( text ) ;
19163 } ) ;
20164}
21165// Read a line from page and notify .NET
@@ -47,7 +191,7 @@ function readLineFromPage(pageIndex, lineIndex, isPrev, muteVoice) {
47191 if ( currentLineSpans && ! muteVoice ) {
48192 const lineText = currentLineSpans . map ( s => s . textContent ) . join ( ' ' ) ;
49193 requestAnimationFrame ( ( ) => {
50- speakText ( lineText , clearAllHighlights ) ;
194+ speakFromControls ( lineText ) ;
51195 } ) ;
52196 }
53197 }
0 commit comments