Improve sound playback on iOS by priming audio elements

Refactor the `NotificationPlayer` class to correctly handle audio playback initiation on iOS, ensuring that sounds play reliably after user gestures by properly managing the unlocked state based on successful priming of HTMLAudioElements.
This commit is contained in:
Riyadh
2026-05-10 11:41:47 +00:00
parent ab1d1e207e
commit 856cfad90c
+26 -23
View File
@@ -104,10 +104,11 @@ class NotificationPlayer {
isUnlocked(): boolean {
if (!this.unlocked) return false;
if (this.useIos) {
// On iOS the "context" concept doesn't apply — being unlocked
// means at least one HTMLAudioElement has cleared its silent
// play+pause inside a gesture. We trust the flag.
return true;
// On iOS, "unlocked" is set only once at least one
// HTMLAudioElement has finished its silent priming play+pause
// inside a gesture (see `unlock()` for why this matters for
// hint suppression timing).
return this.htmlAudioUnlocked.size > 0;
}
return this.ctx?.state === "running";
}
@@ -227,24 +228,34 @@ class NotificationPlayer {
// burst of all 8 sounds at once. After the silent play resolves
// we pause + restore volume + reset currentTime so the next
// real play() starts from the beginning at full volume.
//
// We only flip `this.unlocked = true` once at least one element
// has actually finished a successful priming play. Setting it
// synchronously would cause the hint suppression path in
// `playIos()` (which checks `!this.unlocked`) to swallow a
// legitimate NotAllowedError from a play attempt that arrives
// between the gesture and the moment the priming promise
// settles.
for (const entry of NOTIFICATION_SOUND_MANIFEST) {
if (this.htmlAudioUnlocked.has(entry.id)) continue;
const el = this.getHtmlAudio(entry.id);
if (!el) continue;
const originalVolume = el.volume;
el.volume = 0;
const markPrimed = () => {
try {
el.pause();
el.currentTime = 0;
el.volume = originalVolume;
this.htmlAudioUnlocked.add(entry.id);
this.unlocked = true;
} catch {
/* ignore */
}
};
const p = el.play();
if (p && typeof p.then === "function") {
p.then(() => {
try {
el.pause();
el.currentTime = 0;
el.volume = originalVolume;
this.htmlAudioUnlocked.add(entry.id);
} catch {
/* ignore */
}
}).catch(() => {
p.then(markPrimed).catch(() => {
// Element couldn't start — likely no gesture yet, or the
// user denied it. We'll try again on the next gesture.
try {
@@ -255,17 +266,9 @@ class NotificationPlayer {
});
} else {
// Synchronous return (very old WebKit). Treat as success.
try {
el.pause();
el.currentTime = 0;
el.volume = originalVolume;
this.htmlAudioUnlocked.add(entry.id);
} catch {
/* ignore */
}
markPrimed();
}
}
this.unlocked = true;
return;
}