From dd77ba903baf24fc277d7e2bcba4f97fa5df6fd5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 30 Sep 2026 17:05:32 +0800 Subject: [PATCH] Queue message sound while audio is priming --- web/src/lib/notifications/sound.ts | 12 ++++- .../brand-and-notification-sound.test.mjs | 46 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/web/src/lib/notifications/sound.ts b/web/src/lib/notifications/sound.ts index 779ab134..487702df 100644 --- a/web/src/lib/notifications/sound.ts +++ b/web/src/lib/notifications/sound.ts @@ -1,5 +1,6 @@ let audio: HTMLAudioElement | null = null; let priming = false; +let pendingMessagePlay = false; async function primeSound(): Promise { if (priming) return; @@ -14,9 +15,14 @@ async function primeSound(): Promise { window.removeEventListener('pointerdown', primeSound); window.removeEventListener('keydown', primeSound); } catch { - try { if (audio) audio.muted = false; } catch { /* Audio may be unavailable. */ } + // Keep gesture listeners so the next gesture can retry. } finally { + try { if (audio) audio.muted = false; } catch { /* Audio may be unavailable. */ } priming = false; + if (pendingMessagePlay) { + pendingMessagePlay = false; + playMessageNotificationSound(); + } } } @@ -24,6 +30,10 @@ window.addEventListener('pointerdown', primeSound); window.addEventListener('keydown', primeSound); export function playMessageNotificationSound(): void { + if (priming) { + pendingMessagePlay = true; + return; + } try { audio ??= new Audio('/message-notification.mp3'); audio.currentTime = 0; diff --git a/web/tests/brand-and-notification-sound.test.mjs b/web/tests/brand-and-notification-sound.test.mjs index 03b807c4..339cdf38 100644 --- a/web/tests/brand-and-notification-sound.test.mjs +++ b/web/tests/brand-and-notification-sound.test.mjs @@ -130,6 +130,52 @@ test('failed gesture prime unmutes and retries on the next gesture', async () => assert.equal(attempts, 3); }); +async function delayedPrimeFixture() { + const handlers = new Map(); + window.addEventListener = (type, handler) => handlers.set(type, handler); + window.removeEventListener = (type) => handlers.delete(type); + const plays = []; + let resolvePrime; + let rejectPrime; + const primePlayback = new Promise((resolve, reject) => { resolvePrime = resolve; rejectPrime = reject; }); + let instance; + globalThis.Audio = class { + constructor() { instance = this; } + currentTime = 7; + muted = false; + play() { plays.push(this.muted); return plays.length === 1 ? primePlayback : Promise.resolve(); } + pause() {} + }; + const sound = await freshSound(); + return { handlers, plays, sound, get instance() { return instance; }, resolvePrime, rejectPrime }; +} + +test('messages during a successful delayed prime play once unmuted after priming', async () => { + const fixture = await delayedPrimeFixture(); + const gesture = fixture.handlers.get('pointerdown')(); + fixture.sound.playMessageNotificationSound(); + fixture.sound.playMessageNotificationSound(); + assert.deepEqual(fixture.plays, [true]); + fixture.resolvePrime(); + await gesture; + assert.deepEqual(fixture.plays, [true, false]); + assert.equal(fixture.instance.currentTime, 0); + assert.equal(fixture.instance.muted, false); + assert.equal(fixture.handlers.size, 0); +}); + +test('messages during a failed delayed prime play once unmuted after priming', async () => { + const fixture = await delayedPrimeFixture(); + const gesture = fixture.handlers.get('pointerdown')(); + fixture.sound.playMessageNotificationSound(); + assert.deepEqual(fixture.plays, [true]); + fixture.rejectPrime(new Error('blocked')); + await gesture; + assert.deepEqual(fixture.plays, [true, false]); + assert.equal(fixture.instance.muted, false); + assert.equal(fixture.handlers.size, 2); +}); + test('incoming alerts gate own, visible and muted messages, but sound needs no notification permission', async () => { let plays = 0; globalThis.Audio = class { currentTime = 0; play() { plays++; return Promise.resolve(); } };