From ca07ebc3b773b17c3741522cab5b9e917de00f53 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Sat, 30 May 2026 15:09:03 +0800 Subject: [PATCH] feat(scope): player store scopedBotId + resolveScopedBot helper Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/stores/player.ts | 36 ++++++++++++++++++++++++++++++++++++ web/src/stores/scope.test.ts | 16 ++++++++++++++++ web/src/stores/scope.ts | 10 ++++++++++ 3 files changed, 62 insertions(+) create mode 100644 web/src/stores/scope.test.ts create mode 100644 web/src/stores/scope.ts diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts index 083262c..47ea4e0 100644 --- a/web/src/stores/player.ts +++ b/web/src/stores/player.ts @@ -1,5 +1,6 @@ import { defineStore } from 'pinia'; import axios from 'axios'; +import { resolveScopedBot } from './scope.js'; export interface Song { id: string; @@ -50,6 +51,9 @@ export const usePlayerStore = defineStore('player', { state: () => ({ bots: [] as BotStatus[], activeBotId: null as string | null, + /** When set, the UI is locked to a single bot (dedicated link, from ?bot). + * Source of truth is the URL — never persisted to localStorage. */ + scopedBotId: null as string | null, /** Per-bot queues keyed by botId */ queues: {} as Record, /** Per-bot timing state keyed by botId */ @@ -73,6 +77,10 @@ export const usePlayerStore = defineStore('player', { activeBot(): BotStatus | null { return this.bots.find((b) => b.id === this.activeBotId) ?? this.bots[0] ?? null; }, + /** True when the UI is locked to a single bot via a dedicated link. */ + isScoped(): boolean { + return this.scopedBotId !== null; + }, currentSong(): Song | null { return this.activeBot?.currentSong ?? null; }, @@ -125,6 +133,8 @@ export const usePlayerStore = defineStore('player', { }, setActiveBotId(id: string) { + // While scoped to a dedicated link, switching bots is blocked. + if (this.scopedBotId !== null && id !== this.scopedBotId) return; this.activeBotId = id; // Fetch queue for newly active bot if we don't have it yet if (!this.queues[id]) { @@ -132,6 +142,32 @@ export const usePlayerStore = defineStore('player', { } }, + /** Lock the UI to a single bot (dedicated link). Sets scope first so the + * setActiveBotId guard does not block the switch to the scoped bot. */ + setScope(id: string) { + this.scopedBotId = id; + this.activeBotId = id; + // Lazily fetch this bot's queue, mirroring setActiveBotId. + if (!this.queues[id]) { + this.fetchQueue(); + } + }, + + clearScope() { + this.scopedBotId = null; + }, + + /** Reconcile the scope with the desired id from the URL (?bot). A stale or + * forbidden id resolves to null and clears the scope rather than locking. */ + applyScopeFromQuery(requestedId: string | null) { + const r = resolveScopedBot(requestedId, this.bots.map((b) => b.id)); + if (r) { + this.setScope(r); + } else if (requestedId) { + this.clearScope(); + } + }, + updateBotStatus(botId: string, status: BotStatus) { const prev = this.bots.find((b) => b.id === botId); const prevSongId = prev?.currentSong?.id; diff --git a/web/src/stores/scope.test.ts b/web/src/stores/scope.test.ts new file mode 100644 index 0000000..282e68e --- /dev/null +++ b/web/src/stores/scope.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest"; +import { resolveScopedBot } from "./scope.js"; + +describe("resolveScopedBot", () => { + it("returns null when no id requested", () => { + expect(resolveScopedBot(null, ["a", "b"])).toBeNull(); + expect(resolveScopedBot(undefined, ["a"])).toBeNull(); + expect(resolveScopedBot("", ["a"])).toBeNull(); + }); + it("returns the id when it exists in the bot list", () => { + expect(resolveScopedBot("b", ["a", "b"])).toBe("b"); + }); + it("clears (null) when the requested id is not a known bot", () => { + expect(resolveScopedBot("ghost", ["a", "b"])).toBeNull(); + }); +}); diff --git a/web/src/stores/scope.ts b/web/src/stores/scope.ts new file mode 100644 index 0000000..2e956ba --- /dev/null +++ b/web/src/stores/scope.ts @@ -0,0 +1,10 @@ +/** Given the desired scoped id (from ?bot) and the known bot ids, decide the + * effective scope. Returns the id if it exists, else null (graceful clear: + * a stale/forbidden id never locks the UI). */ +export function resolveScopedBot( + requestedId: string | null | undefined, + knownBotIds: readonly string[], +): string | null { + if (!requestedId) return null; + return knownBotIds.includes(requestedId) ? requestedId : null; +}