Task #462: Fix iPad notification sounds (second attempt)
Original task: After #461 added an HTMLAudioElement playback path for iOS, users still reported no sound on iPad. #462 identifies and fixes three bugs in that earlier attempt. Root causes addressed: 1. iOS unlocks ONE element per gesture, not all 8. The previous loop over 8 separate Audio elements left 7 of them gesture-locked. 2. `crossOrigin = "anonymous"` flipped same-origin .wav requests into CORS mode, causing silent load failure in Safari. 3. `el.volume = 0` is read-only on iOS, so the "silent priming" idea in #461 would have played 8 real chimes at once on first tap. Implementation: - Replaced the 8-element Map with a SINGLE shared HTMLAudioElement on iOS; rotate `.src` per play (iOS unlock is element-bound, not URL). - Added `SILENT_WAV_DATA_URL`: a 60-byte inline silent WAV used to prime the element in `unlock()` — no network, no audible output. - Removed `crossOrigin = "anonymous"`. - `testPlay()` on iOS now calls `play()` directly (skipping the silent-prime) so the gesture-bound `play()` lands on the actual sound. Non-iOS keeps the original `unlock()`-then-`play()` order. - `unlock()` is a no-op once primed; deliberately does NOT pause/reset the silent clip in its resolve handler — letting the ~30ms silence end naturally avoids racing with a `playIos()` `src` swap from a click that fires immediately after pointerdown. Public API unchanged: play / testPlay / unlock / isUnlocked / AUTOPLAY_BLOCKED_EVENT all preserve their signatures and observable behavior. Test globals (__txosNotifPlayCount, __txosNotifLastSound, __txosNotifCtxState) preserved. Locale strings (`notifSettings.autoplayHintIos`) already focused on media volume from #461 — no change needed. Validation: `tsc --noEmit` clean. The pre-existing failures in the api-server / test workflow (executive-meetings.ts) are unrelated and explicitly out of scope per the plan. Manual iPad verification is required to confirm the fix in production. Files changed: - artifacts/tx-os/src/lib/notification-sounds.ts (rewritten)
This commit is contained in:
@@ -48,6 +48,27 @@ type WindowWithWebkitAC = Window & {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
};
|
||||
|
||||
/**
|
||||
* A ~30ms-of-silence WAV file encoded as a data URL. Used on iOS to
|
||||
* "prime" the shared HTMLAudioElement inside a user gesture without
|
||||
* making any network request and without producing audible output.
|
||||
*
|
||||
* iOS Safari's autoplay rule is: an `<audio>` element becomes allowed to
|
||||
* play out-of-gesture only after it has had at least one successful
|
||||
* `play()` *inside* a gesture. Crucially, the unlock is keyed on the
|
||||
* **element**, not the URL — once an element has played anything, you
|
||||
* can later swap `.src` to a different file and `.play()` it without
|
||||
* any further gesture. That's why we keep a single shared element on
|
||||
* iOS and just rotate `.src` per sound, instead of trying to unlock
|
||||
* eight separate elements (which fails on iOS — only the first
|
||||
* `play()` per gesture actually counts as the unlock).
|
||||
*
|
||||
* Format: 8 kHz, 16-bit PCM mono, 8 frames (16 bytes) of zero samples.
|
||||
* 60 bytes total. Inline so we don't need an extra HTTP fetch.
|
||||
*/
|
||||
const SILENT_WAV_DATA_URL =
|
||||
"data:audio/wav;base64,UklGRjQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YRAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
/**
|
||||
* NotificationPlayer
|
||||
* --------------------------------------------------------------------
|
||||
@@ -59,14 +80,21 @@ type WindowWithWebkitAC = Window & {
|
||||
* user's expectation of "the sound the page plays."
|
||||
*
|
||||
* • iOS (iPad / iPhone Safari + iPadOS-as-Mac masquerade):
|
||||
* HTMLAudioElement (`new Audio(url)`). On iOS, Web Audio is routed
|
||||
* through the *ringer* channel, which is silenced by the silent
|
||||
* switch / Control Center mute and uses the *ringer* volume — even
|
||||
* when the user has the *media* volume turned up (which is the
|
||||
* volume that controls YouTube and other media). HTMLAudioElement
|
||||
* plays through the media channel, identical to a `<video>` tag,
|
||||
* so notifications stay audible whenever YouTube/Safari videos
|
||||
* would also be audible.
|
||||
* A SINGLE shared `HTMLAudioElement` whose `.src` we rotate per
|
||||
* play. On iOS, Web Audio is routed through the *ringer* channel,
|
||||
* which is silenced by the silent switch / Control Center mute and
|
||||
* uses the *ringer* volume — even when the user has the *media*
|
||||
* volume turned up (which is the volume that controls YouTube and
|
||||
* other media). HTMLAudioElement plays through the media channel,
|
||||
* identical to a `<video>` tag, so notifications stay audible
|
||||
* whenever YouTube/Safari videos would also be audible.
|
||||
*
|
||||
* We deliberately keep ONE element (not one-per-sound) because iOS
|
||||
* only honors a single `play()` per user gesture as the "unlock"
|
||||
* for that element. Looping `play()` over eight different elements
|
||||
* in a gesture handler unlocks only the first; the rest stay
|
||||
* gesture-locked and silently fail when a socket event tries to
|
||||
* play them later.
|
||||
*
|
||||
* Both engines share the same throttling, autoplay-hint, vibration,
|
||||
* and test-only observability so callers (`socket`, settings preview)
|
||||
@@ -84,12 +112,9 @@ class NotificationPlayer {
|
||||
private loading = new Map<NotificationSoundId, Promise<AudioBuffer | null>>();
|
||||
|
||||
// ---- HTMLAudioElement (iOS) state ----
|
||||
// One element per sound id, reused across plays. iOS requires that
|
||||
// each element be `.play()`-ed at least once inside a user gesture
|
||||
// before it can be played later out-of-gesture; we satisfy that in
|
||||
// `unlock()` by playing-then-pausing every element silently.
|
||||
private htmlAudios = new Map<NotificationSoundId, HTMLAudioElement>();
|
||||
private htmlAudioUnlocked = new Set<NotificationSoundId>();
|
||||
// ONE shared element. We rotate `.src` per play. Created lazily on
|
||||
// first use so SSR-safe.
|
||||
private iosAudio: HTMLAudioElement | null = null;
|
||||
|
||||
// ---- Shared state ----
|
||||
private unlocked = false;
|
||||
@@ -103,13 +128,7 @@ class NotificationPlayer {
|
||||
|
||||
isUnlocked(): boolean {
|
||||
if (!this.unlocked) return false;
|
||||
if (this.useIos) {
|
||||
// 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;
|
||||
}
|
||||
if (this.useIos) return true;
|
||||
return this.ctx?.state === "running";
|
||||
}
|
||||
|
||||
@@ -180,25 +199,23 @@ class NotificationPlayer {
|
||||
// ---- HTMLAudioElement helpers (iOS) ----
|
||||
|
||||
/**
|
||||
* Lazily build (or fetch the cached) HTMLAudioElement for a sound.
|
||||
* `preload="auto"` lets Safari start fetching the file early so the
|
||||
* first play after unlock has its data ready.
|
||||
* Lazily build (or fetch the cached) shared HTMLAudioElement for the
|
||||
* iOS engine. We deliberately do NOT set `crossOrigin` — the .wav
|
||||
* files are served same-origin without CORS headers, and setting
|
||||
* `crossOrigin = "anonymous"` flips the request into CORS mode and
|
||||
* causes Safari to fail the load silently.
|
||||
*/
|
||||
private getHtmlAudio(soundId: NotificationSoundId): HTMLAudioElement | null {
|
||||
private getIosAudio(): HTMLAudioElement | null {
|
||||
if (this.iosAudio) return this.iosAudio;
|
||||
if (typeof window === "undefined") return null;
|
||||
const cached = this.htmlAudios.get(soundId);
|
||||
if (cached) return cached;
|
||||
const entry = SOUND_BY_ID.get(soundId);
|
||||
if (!entry) return null;
|
||||
try {
|
||||
const el = new Audio(entry.url);
|
||||
const el = new Audio();
|
||||
el.preload = "auto";
|
||||
// `playsInline` is a video-only attribute on the type, but
|
||||
// setting it on an Audio element is harmless and silences a
|
||||
// theoretical fullscreen-takeover path on very old iOS.
|
||||
(el as HTMLAudioElement & { playsInline?: boolean }).playsInline = true;
|
||||
el.crossOrigin = "anonymous";
|
||||
this.htmlAudios.set(soundId, el);
|
||||
this.iosAudio = el;
|
||||
return el;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -215,59 +232,50 @@ class NotificationPlayer {
|
||||
* buffer (iOS Safari's own trick, kept for any WebKit-derived
|
||||
* non-iOS browser still requiring it), then pre-decode all buffers.
|
||||
*
|
||||
* On iOS we instead play-then-pause every HTMLAudioElement silently
|
||||
* inside the gesture so each one is permanently allowed to play
|
||||
* later (e.g. when a socket event arrives outside any gesture).
|
||||
* On iOS we point the shared element at a tiny silent WAV data URL
|
||||
* and `play()` it. That counts as the gesture-bound unlock for the
|
||||
* element; afterwards we can swap `.src` to any real sound and play
|
||||
* it from outside any gesture (e.g. when a socket event arrives).
|
||||
*
|
||||
* Safe to call repeatedly; both engines tolerate it.
|
||||
* Safe to call repeatedly; both engines tolerate it. On iOS we
|
||||
* short-circuit once `unlocked` is true so subsequent pointerdowns
|
||||
* don't race with an in-flight `play()` of a real sound.
|
||||
*/
|
||||
unlock(): void {
|
||||
if (this.useIos) {
|
||||
// iOS path: walk every sound and prime its <audio> element. We
|
||||
// mute it for the priming play so the user doesn't hear a
|
||||
// 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 */
|
||||
}
|
||||
};
|
||||
if (this.unlocked) return;
|
||||
const el = this.getIosAudio();
|
||||
if (!el) return;
|
||||
try {
|
||||
el.src = SILENT_WAV_DATA_URL;
|
||||
el.currentTime = 0;
|
||||
const p = el.play();
|
||||
if (p && typeof p.then === "function") {
|
||||
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 {
|
||||
el.volume = originalVolume;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Deliberately no `pause()` / `currentTime = 0` cleanup in
|
||||
// the resolve branch: the silent clip is ~30ms and inaudible,
|
||||
// so we just let it run to completion. Any cleanup here would
|
||||
// race with a `playIos()` that arrives in the same gesture
|
||||
// (e.g. `pointerdown → unlock()` followed immediately by
|
||||
// `click → testPlay() → playIos()`); the cleanup could pause
|
||||
// the real chime that just started. Letting the silent clip
|
||||
// finish naturally — or get pre-empted by a `src` swap from
|
||||
// `playIos()` (which simply aborts this promise) — is safe
|
||||
// either way.
|
||||
p.then(() => {
|
||||
this.unlocked = true;
|
||||
}).catch(() => {
|
||||
// Likely either: no real gesture yet (programmatic call)
|
||||
// and the autoplay policy denied us; or the silent play
|
||||
// was pre-empted by a real chime via `playIos()` swapping
|
||||
// `.src` (AbortError). Both are benign here — `playIos()`
|
||||
// sets `unlocked = true` itself on its own resolve.
|
||||
});
|
||||
} else {
|
||||
// Synchronous return (very old WebKit). Treat as success.
|
||||
markPrimed();
|
||||
this.unlocked = true;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -369,26 +377,39 @@ class NotificationPlayer {
|
||||
}
|
||||
|
||||
private playIos(soundId: NotificationSoundId): void {
|
||||
const el = this.getHtmlAudio(soundId);
|
||||
if (!el) {
|
||||
const el = this.getIosAudio();
|
||||
const entry = SOUND_BY_ID.get(soundId);
|
||||
if (!el || !entry) {
|
||||
if (!this.unlocked) this.dispatchHint();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Rotate the src to the requested sound. Setting src on an
|
||||
// already-unlocked element keeps the unlock — the unlock is
|
||||
// bound to the element, not the URL.
|
||||
el.src = entry.url;
|
||||
el.currentTime = 0;
|
||||
} catch {
|
||||
/* some browsers throw if not seekable yet — safe to ignore */
|
||||
/* ignore */
|
||||
}
|
||||
const p = el.play();
|
||||
if (p && typeof p.then === "function") {
|
||||
p.catch(() => {
|
||||
p.then(() => {
|
||||
// A successful play also confirms the element is unlocked, so
|
||||
// `isUnlocked()` reflects reality even if `unlock()` itself
|
||||
// was never called (e.g. `testPlay()` from inside a click).
|
||||
this.unlocked = true;
|
||||
}).catch(() => {
|
||||
// Most common rejections: NotAllowedError (autoplay policy
|
||||
// before any gesture), or a transient AbortError from an
|
||||
// immediate currentTime reset on the next play. The hint
|
||||
// toast covers the autoplay case; the abort case is benign
|
||||
// and we don't want to spam the user.
|
||||
// before any gesture), or a transient AbortError from a
|
||||
// pause/seek/src-swap racing with the play. The hint toast
|
||||
// covers the autoplay case; the abort case is benign and we
|
||||
// don't want to spam the user.
|
||||
if (!this.unlocked) this.dispatchHint();
|
||||
});
|
||||
} else {
|
||||
// Synchronous return — assume success.
|
||||
this.unlocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,10 +442,23 @@ class NotificationPlayer {
|
||||
/**
|
||||
* Force-play used by the "Test sound" button in settings. Bypasses
|
||||
* the 3-second throttle (so users can audition several sounds in a
|
||||
* row) and unlocks the audio first since this is always called from
|
||||
* inside a click handler.
|
||||
* row).
|
||||
*
|
||||
* On iOS, the requested sound's `play()` MUST happen synchronously
|
||||
* inside the click handler — that's the gesture iOS binds the
|
||||
* unlock to. We therefore call `play()` first (which doubles as the
|
||||
* unlock) instead of running `unlock()`'s silent-prime first, which
|
||||
* could consume the gesture allowance and leave the real play
|
||||
* stranded outside it.
|
||||
*
|
||||
* On non-iOS we keep the original `unlock()`-then-`play()` order so
|
||||
* the AudioContext is reliably resumed before the buffer plays.
|
||||
*/
|
||||
testPlay(soundId: NotificationSoundId): void {
|
||||
if (this.useIos) {
|
||||
this.play(soundId, { bypassThrottle: true });
|
||||
return;
|
||||
}
|
||||
this.unlock();
|
||||
this.play(soundId, { bypassThrottle: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user