From 2ab43b3fde1f1f5120f73d03bd150cd3578bcb16 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:22:35 +0300 Subject: [PATCH] Don't replay SQ/tournament sounds when switching tabs --- .../global-status-sounds.browser.test.tsx | 37 +++++++++++++++++++ .../global-status/global-status-sounds.ts | 33 ++++++++++++++++- .../2026-10-03-sq-sound-multiple-tabs.md | 5 +++ 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 changelog/2026-10-03-sq-sound-multiple-tabs.md diff --git a/app/features/global-status/global-status-sounds.browser.test.tsx b/app/features/global-status/global-status-sounds.browser.test.tsx index 81e5c4d48..93127abb4 100644 --- a/app/features/global-status/global-status-sounds.browser.test.tsx +++ b/app/features/global-status/global-status-sounds.browser.test.tsx @@ -43,6 +43,7 @@ const queued = (overrides: Partial = {}): GlobalStatus => ({ describe("useGlobalStatusSounds", () => { beforeEach(() => { mocks.playSound.mockClear(); + localStorage.clear(); }); test.each([ @@ -99,4 +100,40 @@ describe("useGlobalStatusSounds", () => { expect(mocks.playSound).not.toHaveBeenCalled(); }); + + test("stays silent for a moment another tab already announced", async () => { + const match: GlobalStatus = { state: "SQ_MATCH", url: sendouQMatchPage(1) }; + const staleTab = await render(); + await transitionTo(queued(), match); + mocks.playSound.mockClear(); + + await staleTab.rerender(); + + expect(mocks.playSound).not.toHaveBeenCalled(); + }); + + test("stays silent for a like another tab already announced", async () => { + const staleTab = await render(); + await transitionTo(queued({ count: 1 }), queued({ count: 2 })); + mocks.playSound.mockClear(); + + await staleTab.rerender(); + + expect(mocks.playSound).not.toHaveBeenCalled(); + }); + + test("plays a repeated ready check after the queued status was seen in between", async () => { + const readyCheck: GlobalStatus = { + state: "SQ_READY_CHECK", + url: SENDOUQ_READY_PAGE, + }; + const screen = await render(); + await screen.rerender(); + await screen.rerender(); + mocks.playSound.mockClear(); + + await screen.rerender(); + + expect(mocks.playSound).toHaveBeenCalledWith("sq_ready-check"); + }); }); diff --git a/app/features/global-status/global-status-sounds.ts b/app/features/global-status/global-status-sounds.ts index f4650ba31..f98413d96 100644 --- a/app/features/global-status/global-status-sounds.ts +++ b/app/features/global-status/global-status-sounds.ts @@ -1,5 +1,7 @@ import * as React from "react"; +import * as v from "valibot"; import { playSound } from "~/features/chat/chat-utils"; +import * as PersistedState from "~/modules/persisted-state/persisted-state"; import type { GlobalStatus, GlobalStatusState } from "./global-status-types"; const SOUND_BY_STATE: Partial> = { @@ -8,6 +10,13 @@ const SOUND_BY_STATE: Partial> = { TO_MATCH: "tournament_match", }; +const lastSeenStatusPersisted = PersistedState.define({ + key: "global-status-last-seen", + storage: "local", + schema: v.nullable(v.string()), + default: null, +}); + /** * Plays the alert sound of a status the user has just moved into (a ready * check starting, a match being ready to play) as well as of a like landing @@ -15,7 +24,8 @@ const SOUND_BY_STATE: Partial> = { * anywhere on the site rather than only on the page the moment belongs to. * * The first status seen never plays: a sound announces a moment arriving, not - * one already underway when the page was opened. + * one already underway when the page was opened. Neither does a status another + * tab has already seen, so a backgrounded tab catching up stays silent. */ export function useGlobalStatusSounds(status: GlobalStatus | null) { const previousRef = React.useRef(undefined); @@ -24,13 +34,32 @@ export function useGlobalStatusSounds(status: GlobalStatus | null) { const previous = previousRef.current; previousRef.current = status; - if (previous === undefined || !status) return; + const seenByAnotherTab = markSeenAcrossTabs(status); + + if (previous === undefined || !status || seenByAnotherTab) return; const sound = soundForTransition(previous, status); if (sound) playSound(sound); }, [status]); } +/** Records the status as the latest seen by any tab, returning whether it already was. */ +function markSeenAcrossTabs(status: GlobalStatus | null) { + const key = status ? statusKey(status) : null; + if (PersistedState.read(lastSeenStatusPersisted) === key) return true; + + PersistedState.write(lastSeenStatusPersisted, key); + return false; +} + +function statusKey(status: GlobalStatus) { + if (status.state === "SQ_QUEUED") { + return `${status.state}:${status.groupId}:${status.count ?? 0}`; + } + + return `${status.state}:${status.url}`; +} + function soundForTransition( previous: GlobalStatus | null, next: GlobalStatus, diff --git a/changelog/2026-10-03-sq-sound-multiple-tabs.md b/changelog/2026-10-03-sq-sound-multiple-tabs.md new file mode 100644 index 000000000..11db65262 --- /dev/null +++ b/changelog/2026-10-03-sq-sound-multiple-tabs.md @@ -0,0 +1,5 @@ +--- +navItem: [sendouq, medal] +type: bug +--- +SendouQ and tournament alert sounds no longer replay when switching to another sendou.ink tab that's open