mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-28 02:07:05 -04:00
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:
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user