mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-11 12:19:05 +00:00
refactor(active-tab): rename tab-lock to active-tab for clarity
The "lock" framing was misleading — there's no OS-level mutex; this is a soft coordinator that decides which tab is the active one. Rename makes the role obvious at the import site. - file: tab-lock.svelte.js → active-tab.svelte.js (+ test) - export: tabLock → activeTab - field: .frozen → .inactive (positive form: this tab is inactive) - fn: startTabLock → watchActiveTab - fn: reclaimTab → claimActiveTab - BroadcastChannel name: loto_tab_lock → loto_active_tab No behavior change. Banner copy already updated separately.
This commit is contained in:
1 parent
64e496b777
commit
3d988e1fc4
5 files changed
+96
-93
No files matched your search
@@ -23,7 +23,7 @@
|
||||
|------|---------|
|
||||
| `src/lib/game-logic.js` | Stateless utilities: generateGrid (constraint-aware picker — exact 5 per row & per col, ascending-sorted columns, soft "no 3 consecutive filled cols per row" via rejection sampling), saveGrid, loadGrid, saveCrossedState, loadCrossedState, isRowComplete, getWaitingNumber. |
|
||||
| `src/lib/master-store.svelte.js` | Shared reactive `{called, remaining}` $state for the master deck, persisted to `loto_master`. Exports `masterState`, `loadMaster`, `saveMaster`, `startNewGame`, `drawNext`, `resetMaster`. Hydrated once via `+layout.svelte`'s `onMount` so player-side reads see the full history regardless of mount order. Replaced the single-slot `call-bus` to fix history-loss bugs (regen, reload, multi-tab) — see `plans/reports/code-reviewer-260430-2024-both-mode-consistency.md`. |
|
||||
| `src/lib/tab-lock.svelte.js` | Single-tab guard via `BroadcastChannel`. New tab broadcasts `claim`; old tab sets `tabLock.frozen = true`. `+layout.svelte` mounts it on boot and renders a fullscreen overlay banner ("Loto đã mở ở tab khác. Tap để chuyển về tab này.") when frozen — tap calls `reclaimTab` which broadcasts back, freezing the other tab in turn. No-op in browsers without `BroadcastChannel` (legacy iOS Safari ≤15.4). Prevents double auto-call intervals, double localStorage writers, and overlapping audio across tabs. |
|
||||
| `src/lib/active-tab.svelte.js` | Single-active-tab coordinator via `BroadcastChannel`. New tab broadcasts `claim`; old tab sets `activeTab.inactive = true`. `+layout.svelte` mounts `watchActiveTab()` on boot and renders a fullscreen overlay banner ("Phiên Lô tô đang chạy ở tab khác. Nhấn để tiếp tục tại đây.") when inactive — nhấn calls `claimActiveTab()` which broadcasts back, inactivating the other tab in turn. Soft coordination only (cooperating tabs); no-op in browsers without `BroadcastChannel` (legacy iOS Safari ≤15.4). Prevents double auto-call intervals, double localStorage writers, and overlapping audio across tabs. |
|
||||
| `src/lib/player-auto-cross.js` | Pure `applyMasterCalls({grid, crossed, called, lastHandledIndex, manualUnticks, mode})`. Cursor-by-index dedup (vs the retired `at`-timestamp model), so any caller can pass `lastHandledIndex: 0` to replay master's full history (used by player regen + "Xoá đánh dấu" in both mode). Manual unticks suppress re-cross on replay. |
|
||||
| `src/lib/vietnamese-number.js` | `numberToVietnamese(n)` — pure utility mapping 0..90 to spoken Vietnamese, with tonal exceptions (15 → "mười lăm", 21 → "hai mươi mốt", 25 → "hai mươi lăm"). Out-of-range falls back to `String(n)`. |
|
||||
| `src/lib/voice.js` | Bundled-MP3 playback. Exports `playNumber(n)`, `playWaiting(n)` (sequences cho + N), `playBingo()`, `cancelPlayback()`. Lazy `<audio>` cache, cancel-then-play, token-based cancel ensures stale promises can't resume after a new event. Reads active voice from `settings.voice`; URLs go through `import { base } from "$app/paths"` for basePath safety. |
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Single-active-tab coordinator. When the user opens the app in a
|
||||
* second tab, the second tab broadcasts a claim and the first tab
|
||||
* marks itself inactive until the user explicitly takes it back.
|
||||
* Prevents double auto-call intervals, double localStorage writers,
|
||||
* and overlapping audio across tabs of the same origin.
|
||||
*
|
||||
* Soft coordination only — relies on cooperating tabs, not OS-level
|
||||
* locks. No-op in browsers without `BroadcastChannel` (legacy iOS
|
||||
* Safari ≤15.4); the prior race remains in those edge environments.
|
||||
*
|
||||
* @module lib/active-tab
|
||||
*/
|
||||
|
||||
const CHANNEL = "loto_active_tab";
|
||||
|
||||
const TAB_ID =
|
||||
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `tab-${Math.random().toString(36).slice(2)}-${Date.now()}`;
|
||||
|
||||
export const activeTab = $state({
|
||||
/** True when another tab has claimed; this tab should pause its UI. */
|
||||
inactive: false,
|
||||
});
|
||||
|
||||
/** @type {BroadcastChannel | null} */
|
||||
let bc = null;
|
||||
|
||||
/**
|
||||
* Open the channel, listen for peer claims, and announce that THIS tab
|
||||
* is now the active one. Returns a cleanup that closes the channel.
|
||||
* Calling more than once is a no-op so layout HMR doesn't double-bind.
|
||||
*/
|
||||
export function watchActiveTab() {
|
||||
if (typeof BroadcastChannel === "undefined") return () => {};
|
||||
if (bc) return () => {};
|
||||
bc = new BroadcastChannel(CHANNEL);
|
||||
bc.onmessage = (e) => {
|
||||
if (e.data?.type === "claim" && e.data.id !== TAB_ID) {
|
||||
activeTab.inactive = true;
|
||||
}
|
||||
};
|
||||
bc.postMessage({ type: "claim", id: TAB_ID });
|
||||
return () => {
|
||||
if (bc) {
|
||||
bc.close();
|
||||
bc = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Take active status back from the inactive banner. Marks this tab
|
||||
* active locally and broadcasts a claim — any peer freezes itself in
|
||||
* turn so the handover is symmetric.
|
||||
*/
|
||||
export function claimActiveTab() {
|
||||
activeTab.inactive = false;
|
||||
if (bc) bc.postMessage({ type: "claim", id: TAB_ID });
|
||||
}
|
||||
@@ -44,41 +44,41 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("tab-lock", () => {
|
||||
it("startTabLock returns no-op cleanup when BroadcastChannel is missing", async () => {
|
||||
describe("active-tab", () => {
|
||||
it("watchActiveTab returns no-op cleanup when BroadcastChannel is missing", async () => {
|
||||
vi.stubGlobal("BroadcastChannel", undefined);
|
||||
const mod = await import("./tab-lock.svelte.js?nobc");
|
||||
const cleanup = mod.startTabLock();
|
||||
const mod = await import("./active-tab.svelte.js?nobc");
|
||||
const cleanup = mod.watchActiveTab();
|
||||
expect(typeof cleanup).toBe("function");
|
||||
expect(mod.tabLock.frozen).toBe(false);
|
||||
expect(mod.activeTab.inactive).toBe(false);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("second tab's claim freezes the first tab", async () => {
|
||||
const tabA = await import("./tab-lock.svelte.js?a");
|
||||
const tabB = await import("./tab-lock.svelte.js?b");
|
||||
tabA.startTabLock();
|
||||
expect(tabA.tabLock.frozen).toBe(false);
|
||||
tabB.startTabLock();
|
||||
expect(tabA.tabLock.frozen).toBe(true);
|
||||
expect(tabB.tabLock.frozen).toBe(false);
|
||||
it("second tab's claim marks the first tab inactive", async () => {
|
||||
const tabA = await import("./active-tab.svelte.js?a");
|
||||
const tabB = await import("./active-tab.svelte.js?b");
|
||||
tabA.watchActiveTab();
|
||||
expect(tabA.activeTab.inactive).toBe(false);
|
||||
tabB.watchActiveTab();
|
||||
expect(tabA.activeTab.inactive).toBe(true);
|
||||
expect(tabB.activeTab.inactive).toBe(false);
|
||||
});
|
||||
|
||||
it("reclaimTab unfreezes self and freezes the other", async () => {
|
||||
const tabA = await import("./tab-lock.svelte.js?ra-a");
|
||||
const tabB = await import("./tab-lock.svelte.js?ra-b");
|
||||
tabA.startTabLock();
|
||||
tabB.startTabLock();
|
||||
expect(tabA.tabLock.frozen).toBe(true);
|
||||
tabA.reclaimTab();
|
||||
expect(tabA.tabLock.frozen).toBe(false);
|
||||
expect(tabB.tabLock.frozen).toBe(true);
|
||||
it("claimActiveTab reactivates self and inactivates the other", async () => {
|
||||
const tabA = await import("./active-tab.svelte.js?ra-a");
|
||||
const tabB = await import("./active-tab.svelte.js?ra-b");
|
||||
tabA.watchActiveTab();
|
||||
tabB.watchActiveTab();
|
||||
expect(tabA.activeTab.inactive).toBe(true);
|
||||
tabA.claimActiveTab();
|
||||
expect(tabA.activeTab.inactive).toBe(false);
|
||||
expect(tabB.activeTab.inactive).toBe(true);
|
||||
});
|
||||
|
||||
it("startTabLock is idempotent within a single module", async () => {
|
||||
const tab = await import("./tab-lock.svelte.js?idem");
|
||||
const c1 = tab.startTabLock();
|
||||
const c2 = tab.startTabLock();
|
||||
it("watchActiveTab is idempotent within a single module", async () => {
|
||||
const tab = await import("./active-tab.svelte.js?idem");
|
||||
const c1 = tab.watchActiveTab();
|
||||
const c2 = tab.watchActiveTab();
|
||||
expect(typeof c1).toBe("function");
|
||||
expect(typeof c2).toBe("function");
|
||||
c1();
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* Single-tab guard for the Lô tô app. When the user opens the app in a
|
||||
* second tab, the second tab broadcasts a claim and the first tab
|
||||
* freezes itself until the user explicitly takes it back. Prevents
|
||||
* double auto-call intervals, double localStorage writers, and
|
||||
* overlapping audio across tabs of the same origin.
|
||||
*
|
||||
* No-op in browsers without `BroadcastChannel` (legacy iOS Safari).
|
||||
*
|
||||
* @module lib/tab-lock
|
||||
*/
|
||||
|
||||
const CHANNEL = "loto_tab_lock";
|
||||
|
||||
const TAB_ID =
|
||||
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `tab-${Math.random().toString(36).slice(2)}-${Date.now()}`;
|
||||
|
||||
export const tabLock = $state({
|
||||
/** True when another tab has taken over and this tab should pause. */
|
||||
frozen: false,
|
||||
});
|
||||
|
||||
/** @type {BroadcastChannel | null} */
|
||||
let bc = null;
|
||||
|
||||
/**
|
||||
* Start listening for tab-claim messages and immediately announce that
|
||||
* THIS tab is now active. Returns a cleanup that closes the channel.
|
||||
* Calling more than once is a no-op so layout HMR doesn't double-bind.
|
||||
*/
|
||||
export function startTabLock() {
|
||||
if (typeof BroadcastChannel === "undefined") return () => {};
|
||||
if (bc) return () => {};
|
||||
bc = new BroadcastChannel(CHANNEL);
|
||||
bc.onmessage = (e) => {
|
||||
if (e.data?.type === "claim" && e.data.id !== TAB_ID) {
|
||||
tabLock.frozen = true;
|
||||
}
|
||||
};
|
||||
bc.postMessage({ type: "claim", id: TAB_ID });
|
||||
return () => {
|
||||
if (bc) {
|
||||
bc.close();
|
||||
bc = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-claim the lock from the frozen banner. Unfreezes this tab and
|
||||
* broadcasts a new claim — any other live tab freezes itself in turn.
|
||||
*/
|
||||
export function reclaimTab() {
|
||||
tabLock.frozen = false;
|
||||
if (bc) bc.postMessage({ type: "claim", id: TAB_ID });
|
||||
}
|
||||
@@ -4,29 +4,29 @@
|
||||
import { loadMaster } from "$lib/master-store.svelte.js";
|
||||
import { loadSettings } from "$lib/settings-store.svelte.js";
|
||||
import {
|
||||
reclaimTab,
|
||||
startTabLock,
|
||||
tabLock,
|
||||
} from "$lib/tab-lock.svelte.js";
|
||||
activeTab,
|
||||
claimActiveTab,
|
||||
watchActiveTab,
|
||||
} from "$lib/active-tab.svelte.js";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Hydrate global stores once so children mount with consistent state.
|
||||
// Master state must hydrate before PlayerBoard reads `masterState.called`
|
||||
// length — see the mode-mount race fix in the consistency refactor.
|
||||
// The tab-lock cleanup is returned so HMR / route changes close the
|
||||
// The active-tab cleanup is returned so HMR / route changes close the
|
||||
// BroadcastChannel cleanly.
|
||||
onMount(() => {
|
||||
loadSettings();
|
||||
loadMaster();
|
||||
return startTabLock();
|
||||
return watchActiveTab();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if tabLock.frozen}
|
||||
{#if activeTab.inactive}
|
||||
<button
|
||||
type="button"
|
||||
onclick={reclaimTab}
|
||||
onclick={claimActiveTab}
|
||||
class="fixed inset-0 z-50 flex flex-col items-center justify-center
|
||||
bg-slate-900/95 text-white text-center px-6
|
||||
cursor-pointer focus:outline-none"
|
||||
|
||||
Reference in new issue
Block a user