From fa529cd95a77b3c05b6c1e714c01e1aeab401cb6 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Sun, 10 May 2026 11:41:47 +0000 Subject: [PATCH] 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. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: f541a3b8-ce77-4f70-9fa4-f1272f6e5c7a Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/mkNwf1z Replit-Helium-Checkpoint-Created: true --- .../tx-os/src/lib/notification-sounds.ts | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) 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; }