From 6e828b9c2dbcc119ae90b57a53125946dc71b55a Mon Sep 17 00:00:00 2001 From: saopig1 Date: Fri, 10 Apr 2026 17:16:29 +0800 Subject: [PATCH] fix(ws): re-attach stateChange listeners when bot instance is replaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startBot creates a fresh BotInstance but the WS layer keyed its listener map by bot.id, so ensureAllBotsAttached skipped the new object and stateChange events were never broadcast — the player bar only appeared after a manual refresh. BotManager now extends EventEmitter and emits "botInstance" whenever a new bot object is created; websocket.ts stores the bot reference, detaches on replacement, and subscribes to the event for immediate wiring. Also enlarges the bot selector (padding 10×20, font 16, min-height 44, bigger dot/chevron/state icons, wider name) and adds Playwright repro scripts for the player bar bug and navbar sizing check. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 2 + scripts/screenshot_navbar.py | 12 ++++ scripts/test_playbar.py | 105 ++++++++++++++++++++++++++++++++++ src/bot/manager.ts | 7 ++- src/web/websocket.ts | 39 +++++++++---- web/src/components/Navbar.vue | 18 +++--- 6 files changed, 161 insertions(+), 22 deletions(-) create mode 100644 scripts/screenshot_navbar.py create mode 100644 scripts/test_playbar.py diff --git a/.gitignore b/.gitignore index 23a3dc6..7a588c1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ dist/ config.json cookies/ .superpowers/ +/bin/ +scripts/navbar_bigger.png diff --git a/scripts/screenshot_navbar.py b/scripts/screenshot_navbar.py new file mode 100644 index 0000000..8326fe3 --- /dev/null +++ b/scripts/screenshot_navbar.py @@ -0,0 +1,12 @@ +from playwright.sync_api import sync_playwright + +with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1440, "height": 900}) + page.goto("http://localhost:3000") + page.wait_for_load_state("networkidle") + page.wait_for_timeout(800) + page.locator(".navbar").screenshot(path="scripts/navbar_bigger.png") + rect = page.locator(".bot-selector-btn").bounding_box() + print("bot-selector-btn bbox:", rect) + browser.close() diff --git a/scripts/test_playbar.py b/scripts/test_playbar.py new file mode 100644 index 0000000..45dc41c --- /dev/null +++ b/scripts/test_playbar.py @@ -0,0 +1,105 @@ +"""Reproduce the player-bar-not-appearing bug.""" +import time +import requests +from playwright.sync_api import sync_playwright + +BASE = "http://localhost:3000" + +def get_bot_id(): + r = requests.get(f"{BASE}/api/bot/") + return r.json()["bots"][0]["id"] + +def stop(bot_id): + requests.post(f"{BASE}/api/player/{bot_id}/stop") + requests.post(f"{BASE}/api/player/{bot_id}/clear") + +def play(bot_id, query="test"): + requests.post(f"{BASE}/api/player/{bot_id}/play", json={"query": query, "platform": "netease"}) + +with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + ctx = browser.new_context() + ctx.add_init_script(""" + (() => { + const OrigWS = window.WebSocket; + window.__wsMessages = []; + window.WebSocket = function(...args) { + const ws = new OrigWS(...args); + ws.addEventListener('message', (ev) => { + try { + const d = JSON.parse(ev.data); + const summary = {type: d.type, botId: d.botId}; + if (d.status) summary.playing = d.status.playing; + if (d.status?.currentSong) summary.song = d.status.currentSong.name; + window.__wsMessages.push(summary); + console.log('WS_MSG ' + JSON.stringify(summary)); + } catch(e) {} + }); + return ws; + }; + Object.assign(window.WebSocket, OrigWS); + })(); + """) + page = ctx.new_page() + logs = [] + page.on("console", lambda msg: logs.append(f"[{msg.type}] {msg.text}")) + + bot_id = get_bot_id() + stop(bot_id) + time.sleep(0.6) + + page.goto(BASE) + page.wait_for_load_state("networkidle") + time.sleep(0.8) + + # Hook into WebSocket messages from the page to confirm they arrive + page.evaluate(""" + () => { + const origWS = window.WebSocket; + // Already connected via useWebSocket — tap Pinia store directly + // Expose store state via window + const store = window.__pinia?._s?.get('player'); + window.__getStore = () => ({ + bots: JSON.parse(JSON.stringify(store?.bots || [])), + activeBotId: store?.activeBotId, + currentSong: store?.currentSong ? JSON.parse(JSON.stringify(store.currentSong)) : null, + isPlaying: store?.isPlaying, + }); + } + """) + + snap_before = page.evaluate("() => window.__getStore?.()") + print("store before:", snap_before) + + # Player bar should be absent right now (nothing playing) + initial = page.locator(".player-wrapper").count() + print(f"initial .player-wrapper count: {initial}") + + # Trigger play via API (simulates any play trigger) + play(bot_id, "test") + + # Poll for up to 6s to see if player bar appears automatically + appeared_at = None + for i in range(60): + if page.locator(".player-wrapper").count() > 0: + appeared_at = i * 0.1 + break + page.wait_for_timeout(100) + + print(f"player bar appeared after: {appeared_at}") + snap_after = page.evaluate("() => window.__getStore?.()") + print("store after:", snap_after) + + # After force reload, does it appear? + if appeared_at is None: + page.reload() + page.wait_for_load_state("networkidle") + time.sleep(0.8) + after_reload = page.locator(".player-wrapper").count() + print(f"after reload .player-wrapper count: {after_reload}") + + print("--- console logs ---") + for line in logs[-20:]: + print(line) + + browser.close() diff --git a/src/bot/manager.ts b/src/bot/manager.ts index 08b4c65..8e8acd9 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import { EventEmitter } from "node:events"; import { BotInstance, type BotInstanceOptions, @@ -28,7 +29,7 @@ export interface CreateBotParams { serverPassword?: string; } -export class BotManager { +export class BotManager extends EventEmitter { private bots = new Map(); private neteaseProvider: MusicProvider; private qqProvider: MusicProvider; @@ -46,6 +47,7 @@ export class BotManager { config: BotConfig, logger: Logger ) { + super(); this.neteaseProvider = neteaseProvider; this.qqProvider = qqProvider; this.bilibiliProvider = bilibiliProvider; @@ -82,6 +84,7 @@ export class BotManager { }); this.bots.set(id, bot); + this.emit("botInstance", bot); this.database.saveBotInstance({ id, @@ -179,6 +182,7 @@ export class BotManager { logger: this.logger, }); this.bots.set(id, bot); + this.emit("botInstance", bot); await bot.connect(); // Mark as autoStart so it reconnects on Docker restart, and persist identity this.database.saveBotInstance({ ...saved, autoStart: true }); @@ -229,6 +233,7 @@ export class BotManager { }); this.bots.set(saved.id, bot); + this.emit("botInstance", bot); // Only auto-connect bots that have autoStart enabled if (saved.autoStart) { diff --git a/src/web/websocket.ts b/src/web/websocket.ts index fc8813d..1c52333 100644 --- a/src/web/websocket.ts +++ b/src/web/websocket.ts @@ -10,8 +10,9 @@ export function setupWebSocket( ): () => void { const clients = new Set(); - /** Track which bots already have listeners attached */ + /** Track which bot instances have listeners attached (keyed by id, storing ref) */ const attachedBots = new Map void; connected: () => void; disconnected: () => void; @@ -48,8 +49,22 @@ export function setupWebSocket( } }; + function detachBotListener(id: string): void { + const existing = attachedBots.get(id); + if (!existing) return; + existing.bot.removeListener("stateChange", existing.stateChange); + existing.bot.removeListener("connected", existing.connected); + existing.bot.removeListener("disconnected", existing.disconnected); + attachedBots.delete(id); + } + function attachBotListener(bot: BotInstance): void { - if (attachedBots.has(bot.id)) return; + const existing = attachedBots.get(bot.id); + if (existing) { + if (existing.bot === bot) return; // already attached to this instance + // Bot instance was replaced (e.g. startBot re-created it) — re-attach + detachBotListener(bot.id); + } const onStateChange = () => { broadcast({ @@ -81,6 +96,7 @@ export function setupWebSocket( bot.on("disconnected", onDisconnected); attachedBots.set(bot.id, { + bot, stateChange: onStateChange, connected: onConnected, disconnected: onDisconnected, @@ -94,21 +110,20 @@ export function setupWebSocket( } } - // Check for newly added bots periodically + // React immediately when a bot instance is created or replaced + const onBotInstance = (bot: BotInstance) => attachBotListener(bot); + botManager.on("botInstance", onBotInstance); + + // Safety net: periodically re-check in case any bot was missed const intervalId = setInterval(ensureAllBotsAttached, 5000); ensureAllBotsAttached(); return () => { clearInterval(intervalId); - // Clean up named listeners - for (const bot of botManager.getAllBots()) { - const listeners = attachedBots.get(bot.id); - if (listeners) { - bot.removeListener("stateChange", listeners.stateChange); - bot.removeListener("connected", listeners.connected); - bot.removeListener("disconnected", listeners.disconnected); - } + botManager.removeListener("botInstance", onBotInstance); + // Clean up all attached listeners (detach from stored bot refs, not live map) + for (const id of Array.from(attachedBots.keys())) { + detachBotListener(id); } - attachedBots.clear(); }; } diff --git a/web/src/components/Navbar.vue b/web/src/components/Navbar.vue index f381595..9e8d493 100644 --- a/web/src/components/Navbar.vue +++ b/web/src/components/Navbar.vue @@ -158,13 +158,13 @@ onUnmounted(() => { .bot-selector-btn { display: flex; align-items: center; - gap: 8px; - padding: 8px 16px; + gap: 10px; + padding: 10px 20px; background: var(--hover-bg); border-radius: var(--radius-md); - font-size: 14px; + font-size: 16px; font-weight: 600; - min-height: 36px; + min-height: 44px; border: 1px solid var(--border-color); transition: background var(--transition-fast), border-color var(--transition-fast); cursor: pointer; @@ -176,13 +176,13 @@ onUnmounted(() => { } .bot-state-mini { - font-size: 11px; + font-size: 14px; &.playing { color: #22c55e; } &.paused { color: #eab308; } } .bot-chevron { - font-size: 16px; + font-size: 20px; opacity: 0.5; transition: transform 0.2s ease; @@ -192,8 +192,8 @@ onUnmounted(() => { } .bot-dot { - width: 8px; - height: 8px; + width: 10px; + height: 10px; border-radius: 50%; background: var(--text-tertiary); flex-shrink: 0; @@ -204,7 +204,7 @@ onUnmounted(() => { } .bot-selector-name { - max-width: 120px; + max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;