165 lines
4.5 KiB
JavaScript
165 lines
4.5 KiB
JavaScript
// src/utils/singleTabAudioLeader.js
|
|
// Ensures only one tab ("leader") plays sounds per bodyshop.
|
|
//
|
|
// Storage key: localStorage["imex:sound:leader:<bodyshopId>"] = { id, ts }
|
|
// Channel: new BroadcastChannel("imex:sound:<bodyshopId>")
|
|
|
|
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();
|
|
}
|
|
};
|
|
}
|