diff --git a/artifacts/tx-os/src/lib/notification-sounds.ts b/artifacts/tx-os/src/lib/notification-sounds.ts index f631b701..7784648c 100644 --- a/artifacts/tx-os/src/lib/notification-sounds.ts +++ b/artifacts/tx-os/src/lib/notification-sounds.ts @@ -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; }