// src/utils/singleTabAudioLeader.js // Ensures only one tab ("leader") plays sounds per bodyshop. // // Storage key: localStorage["imex:sound:leader:"] = { id, ts } // Channel: new BroadcastChannel("imex:sound:") const STORAGE_PREFIX = "imex:sound:leader:"; const CHANNEL_PREFIX = "imex:sound:"; const TTL_MS = 60_000; // leader expires after 60s without heartbeat const HEARTBEAT_MS = 20_000; // leader refresh interval const WATCHDOG_MS = 10_000; // how often non-leaders check for stale leader const TAB_ID = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2); function channelSupported() { try { return "BroadcastChannel" in window; } catch { return false; } } function getChannel(bodyshopId) { if (!channelSupported() || !bodyshopId) return null; try { return new BroadcastChannel(CHANNEL_PREFIX + String(bodyshopId)); } catch { return null; } } function lsKey(bodyshopId) { return STORAGE_PREFIX + String(bodyshopId); } function readLeader(bodyshopId) { if (!bodyshopId) return null; try { const raw = localStorage.getItem(lsKey(bodyshopId)); if (!raw) return null; return JSON.parse(raw); } catch { return null; } } function writeLeader(record, bodyshopId) { if (!bodyshopId) return; try { localStorage.setItem(lsKey(bodyshopId), JSON.stringify(record)); const bc = getChannel(bodyshopId); if (bc) { bc.postMessage({ type: "leader-update", payload: { ...record, bodyshopId } }); bc.close(); } } catch { // ignore } } function removeLeader(bodyshopId) { if (!bodyshopId) return; try { const cur = readLeader(bodyshopId); if (cur?.id === TAB_ID) { localStorage.removeItem(lsKey(bodyshopId)); const bc = getChannel(bodyshopId); if (bc) { bc.postMessage({ type: "leader-removed", payload: { id: TAB_ID, bodyshopId } }); bc.close(); } } } catch { // ignore } } function now() { return Date.now(); } function isStale(rec) { return !rec || now() - rec.ts > TTL_MS; } function claimLeadership(bodyshopId) { const rec = { id: TAB_ID, ts: now() }; writeLeader(rec, bodyshopId); return rec; } /** Is THIS tab currently the leader (and not stale)? */ export function isLeaderTab(bodyshopId) { const rec = readLeader(bodyshopId); return !!rec && rec.id === TAB_ID && !isStale(rec); } /** Force this tab to become the leader right now. */ export function claimLeadershipNow(bodyshopId) { return claimLeadership(bodyshopId); } /** * Initialize leader election/heartbeat for this tab (scoped by bodyshopId). * Call once (e.g., in SoundWrapper). Returns a cleanup function. */ export function initSingleTabAudioLeader(bodyshopId) { if (!bodyshopId) return () => { // }; // If no leader or stale, try to claim after a tiny delay (reduce startup contention) if (isStale(readLeader(bodyshopId))) { setTimeout(() => claimLeadership(bodyshopId), 100); } // If this tab becomes focused/visible, it can claim leadership const onFocus = () => claimLeadership(bodyshopId); const onVis = () => { if (document.visibilityState === "visible") claimLeadership(bodyshopId); }; window.addEventListener("focus", onFocus); document.addEventListener("visibilitychange", onVis); // Heartbeat from the leader to keep record fresh const heartbeat = setInterval(() => { if (!isLeaderTab(bodyshopId)) return; writeLeader({ id: TAB_ID, ts: now() }, bodyshopId); }, HEARTBEAT_MS); // Watchdog: if leader is stale, try to claim (even if we're not focused) const watchdog = setInterval(() => { const cur = readLeader(bodyshopId); if (isStale(cur)) claimLeadership(bodyshopId); }, WATCHDOG_MS); // If this tab was the leader, clean up on unload const onUnload = () => removeLeader(bodyshopId); window.addEventListener("beforeunload", onUnload); // Per-bodyshop BroadcastChannel listener (optional/no-op) const bc = getChannel(bodyshopId); const onBC = bc ? () => { // No state kept here; localStorage read is the source of truth. } : null; if (bc && onBC) bc.addEventListener("message", onBC); return () => { window.removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onVis); window.removeEventListener("beforeunload", onUnload); clearInterval(heartbeat); clearInterval(watchdog); if (bc && onBC) { bc.removeEventListener("message", onBC); bc.close(); } }; }