fix: unhandled play() rejection wedges the Read Aloud button; auto-playback silent on iOS (#30373)

On iOS and iPadOS, auto-playback of a finished reply is never heard and the speaker button stays stuck in "speaking", so the first tap only stops a playback that never started. WebKit rejects play() started from a network event, and the audio queue ignored that rejection, so its state never reset. Call mode on the same devices was silent too.

A rejected play() now resets the queue, returns the button to idle and shows a toast (an aborted play from stop or a message switch is ignored). The first user tap or keypress plays a 10 ms silent clip on the shared audio element, which WebKit then allows to play later without a gesture; if that attempt fails it retries on the next gesture. Call mode plays unmuted: WebKit pauses an element that is unmuted after play() outside a gesture, even once unlocked.

The unlock and call mode change follow the reporter's on-device tests (iPhone iOS 27, iPad iPadOS 26.6.2). Verified in Chromium with autoplay restricted: the base queue wedges and drops later chunks; with the fix it reports the error, recovers, the unlock plays once and never interrupts audio already playing, and chunks queued during the unlock still play.

Fixes #30262
This commit is contained in:
Classic298
2026-09-23 23:52:09 -04:00
committed by GitHub
parent 1b5a8ac5a7
commit 7924ce7d79
4 changed files with 52 additions and 14 deletions
@@ -456,21 +456,17 @@
};
audioElement.src = audio.src;
audioElement.muted = true;
// stopAllAudio mutes it; unmuting after play() outside a gesture makes WebKit pause it
audioElement.muted = false;
audioElement.playbackRate = $settings.audio?.tts?.playbackRate ?? 1;
audioElement.onended = finish;
audioElement.onerror = () => finish();
audioElement.onpause = finish;
audioElement
.play()
.then(() => {
audioElement.muted = false;
})
.catch((error) => {
console.error(error);
finish(error);
});
audioElement.play().catch((error) => {
console.error(error);
finish(error);
});
});
} else {
return Promise.resolve();
@@ -287,9 +287,14 @@
} else {
$audioQueue.setId(`${message.id}`);
$audioQueue.setPlaybackRate($settings.audio?.tts?.playbackRate ?? 1);
$audioQueue.onStopped = () => {
$audioQueue.onStopped = ({ event }) => {
speaking = false;
speakingIdx = undefined;
if (event === 'error') {
speakAbort?.abort();
toast.error($i18n.t('Audio playback failed'));
}
};
loadingSpeech = true;
@@ -239,6 +239,7 @@
"Attach Notes": "",
"Attach Webpage": "",
"Attention to detail": "",
"Audio playback failed": "",
"Audio player": "",
"August": "",
"Auth": "",
+39 -3
View File
@@ -1,10 +1,13 @@
type AudioQueueEvent = 'stop' | 'empty-queue' | 'id-change';
type AudioQueueEvent = 'stop' | 'empty-queue' | 'id-change' | 'error';
interface AudioQueueStopDetail {
event: AudioQueueEvent;
id: string | null;
}
const SILENT_WAV =
'data:audio/wav;base64,UklGRsQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YaAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export type OnStoppedCallback = (detail: AudioQueueStopDetail) => void;
export class AudioQueue {
@@ -12,6 +15,7 @@ export class AudioQueue {
private queue: string[] = [];
private current: string | null = null;
private readonly _onEnded = () => this.next();
private readonly _onGesture = () => this.#unlock();
id: string | null = null;
onStopped: OnStoppedCallback | null = null;
@@ -19,6 +23,8 @@ export class AudioQueue {
constructor(audioElement: HTMLAudioElement) {
this.audio = audioElement;
this.audio.addEventListener('ended', this._onEnded);
document.addEventListener('pointerdown', this._onGesture, true);
document.addEventListener('keydown', this._onGesture, true);
}
setId(newId: string) {
@@ -50,12 +56,36 @@ export class AudioQueue {
}
}
/** Play silence inside the first user gesture so WebKit allows later programmatic playback. */
#unlock() {
if (this.current || !this.audio.paused) return;
this.audio.src = SILENT_WAV;
this.audio
.play()
.then(() => this.#removeGestureListeners())
.catch(() => {})
.finally(() => {
if (this.current) return;
this.audio.pause();
if (this.queue.length) this.next();
});
}
next() {
this.current = this.queue.shift() ?? null;
if (this.current) {
this.audio.src = this.current;
this.audio.play();
const url = this.current;
this.audio.src = url;
this.audio.play().catch((error) => {
if (this.current !== url) return;
console.error(error);
this.#halt();
this.onStopped?.({ event: 'error', id: this.id });
});
} else {
this.#halt();
this.onStopped?.({ event: 'empty-queue', id: this.id });
@@ -69,10 +99,16 @@ export class AudioQueue {
destroy() {
this.audio.removeEventListener('ended', this._onEnded);
this.#removeGestureListeners();
this.#halt();
this.onStopped = null;
}
#removeGestureListeners() {
document.removeEventListener('pointerdown', this._onGesture, true);
document.removeEventListener('keydown', this._onGesture, true);
}
/**
* Pause audio and clear queue without firing onStopped.
* Callers that need the callback should invoke it themselves.