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+ const available = speechSynthesis . getVoices ( ) ;
102+ let voice = null ;
103+ voice = available . find ( v => v . default ) || available [ 0 ] ;
104+ if ( voice ) {
105+ utterThis . voice = voice ;
106+ if ( voice . lang ) utterThis . lang = voice . lang ; // Safari iOS respects lang better
107+ }
108+
109+ utterThis . pitch = 1 ;
110+ utterThis . rate = 1 ;
111+
112+ // Safari sometimes needs a microtask delay after cancel before speak
113+ setTimeout ( ( ) => synth . speak ( utterThis ) , 0 ) ;
114+ }
115+
116+ async function initUi ( ) {
117+ await voicesReady ;
118+ // Populate voices now that controls exist
119+ populateVoiceList ( ) ;
120+ if ( speechSynthesis . onvoiceschanged !== undefined ) {
121+ speechSynthesis . onvoiceschanged = populateVoiceList ;
122+ }
123+ return true ;
124+ }
125+
126+ // Initialize when DOM is ready; also handle Blazor re-renders
127+ document . addEventListener ( 'DOMContentLoaded' , async ( ) => {
128+ // Set up iOS unlock listeners early
129+ ensureTtsUnlocked ( ) ;
130+ if ( await initUi ( ) ) return ;
131+ const obs = new MutationObserver ( async ( ) => {
132+ if ( await initUi ( ) ) obs . disconnect ( ) ;
133+ } ) ;
134+ obs . observe ( document . body , { childList : true , subtree : true } ) ;
135+ } ) ;
136+
137+ // Expose a helper to manually unlock from .NET or UI (tap/click)
138+ window . unlockTtsForIOS = ( ) => ensureTtsUnlocked ( ) ;
139+
140+ // Register .NET object for interop
2141 function registerDotNetObject ( dotNetObj ) {
3- window . myDotNetObj = dotNetObj ;
142+ window . myDotNetObj = dotNetObj ;
143+ ensureTtsUnlocked ( ) ;
4144}
5145// Read selected text and highlight
6146function readSelectedText ( args , zoomLevel , muteVoice ) {
@@ -15,7 +155,7 @@ function readSelectedText(args, zoomLevel, muteVoice) {
15155 } ) ;
16156 if ( muteVoice ) return ;
17157 requestAnimationFrame ( ( ) => {
18- speakText ( text , clearAllHighlights ) ;
158+ speakFromControls ( text ) ;
19159 } ) ;
20160}
21161// Read a line from page and notify .NET
@@ -47,7 +187,7 @@ function readLineFromPage(pageIndex, lineIndex, isPrev, muteVoice) {
47187 if ( currentLineSpans && ! muteVoice ) {
48188 const lineText = currentLineSpans . map ( s => s . textContent ) . join ( ' ' ) ;
49189 requestAnimationFrame ( ( ) => {
50- speakText ( lineText , clearAllHighlights ) ;
190+ speakFromControls ( lineText ) ;
51191 } ) ;
52192 }
53193 }
0 commit comments