Skip to content

Commit 293cce5

Browse files
1006970: Resolved iPad issues
1 parent 1ca0ac2 commit 293cce5

2 files changed

Lines changed: 291 additions & 7 deletions

File tree

Accessible/EdgeScreenReader/wwwroot/accessibility.js

Lines changed: 143 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,145 @@
1-

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+
await voicesReady;
85+
86+
const t = (typeof input === 'string' ? input.trim() : (input?.value || '').trim());
87+
if (!t) return;
88+
89+
// Cancel any current speech to avoid overlaps/refresh quirks
90+
if (synth.speaking) {
91+
synth.cancel();
92+
}
93+
94+
const utterThis = new SpeechSynthesisUtterance(t);
95+
96+
utterThis.onend = function () {
97+
console.log("SpeechSynthesisUtterance.onend");
98+
};
99+
100+
utterThis.onerror = function (e) {
101+
console.error("SpeechSynthesisUtterance.onerror", e);
102+
};
103+
104+
const available = speechSynthesis.getVoices();
105+
let voice = null;
106+
voice = available.find(v => v.default) || available[0];
107+
if (voice) {
108+
utterThis.voice = voice;
109+
if (voice.lang) utterThis.lang = voice.lang; // Safari iOS respects lang better
110+
}
111+
112+
utterThis.pitch = 1;
113+
utterThis.rate = 1;
114+
115+
// Safari sometimes needs a microtask delay after cancel before speak
116+
setTimeout(() => synth.speak(utterThis), 0);
117+
}
118+
119+
async function initUi() {
120+
await voicesReady;
121+
// Populate voices now that controls exist
122+
populateVoiceList();
123+
if (speechSynthesis.onvoiceschanged !== undefined) {
124+
speechSynthesis.onvoiceschanged = populateVoiceList;
125+
}
126+
return true;
127+
}
128+
129+
// Initialize when DOM is ready; also handle Blazor re-renders
130+
document.addEventListener('DOMContentLoaded', async () => {
131+
// Set up iOS unlock listeners early
132+
ensureTtsUnlocked();
133+
if (await initUi()) return;
134+
const obs = new MutationObserver(async () => {
135+
if (await initUi()) obs.disconnect();
136+
});
137+
obs.observe(document.body, { childList: true, subtree: true });
138+
});
139+
140+
// Expose a helper to manually unlock from .NET or UI (tap/click)
141+
window.unlockTtsForIOS = () => ensureTtsUnlocked();
142+
2143
// Initialize PDF accessibility features and observe page changes
3144
function initPdfAccessibility() {
4145
const viewerInfo = getViewerInfo();
@@ -81,8 +222,7 @@ function wirePage(div) {
81222
// Reader the selected text aloud - Mircosoft Reader
82223
function readAloudText(text) {
83224
window.speechSynthesis.cancel();
84-
const utterance = new SpeechSynthesisUtterance(text);
85-
window.speechSynthesis.speak(utterance);
225+
speakFromControls(text);
86226
}
87227

88228
// Cancel speech and remove highlights - Mircosoft Reader

Accessible/WebSynthesis/wwwroot/accessibility.js

Lines changed: 148 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,150 @@
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
6150
function 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

Comments
 (0)