fix(ws): re-attach stateChange listeners when bot instance is replaced

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) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.6 committed 2026-04-10 17:16:29 +08:00
1 parent d5d6abeb1e
commit 6e828b9c2d
6 files changed
+161 -22

No files matched your search

+2
View File
@@ -6,3 +6,5 @@ dist/
config.json config.json
cookies/ cookies/
.superpowers/ .superpowers/
/bin/
scripts/navbar_bigger.png
+12
View File
@@ -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()
+105
View File
@@ -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()
+6 -1
View File
@@ -1,4 +1,5 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { EventEmitter } from "node:events";
import { import {
BotInstance, BotInstance,
type BotInstanceOptions, type BotInstanceOptions,
@@ -28,7 +29,7 @@ export interface CreateBotParams {
serverPassword?: string; serverPassword?: string;
} }
export class BotManager { export class BotManager extends EventEmitter {
private bots = new Map<string, BotInstance>(); private bots = new Map<string, BotInstance>();
private neteaseProvider: MusicProvider; private neteaseProvider: MusicProvider;
private qqProvider: MusicProvider; private qqProvider: MusicProvider;
@@ -46,6 +47,7 @@ export class BotManager {
config: BotConfig, config: BotConfig,
logger: Logger logger: Logger
) { ) {
super();
this.neteaseProvider = neteaseProvider; this.neteaseProvider = neteaseProvider;
this.qqProvider = qqProvider; this.qqProvider = qqProvider;
this.bilibiliProvider = bilibiliProvider; this.bilibiliProvider = bilibiliProvider;
@@ -82,6 +84,7 @@ export class BotManager {
}); });
this.bots.set(id, bot); this.bots.set(id, bot);
this.emit("botInstance", bot);
this.database.saveBotInstance({ this.database.saveBotInstance({
id, id,
@@ -179,6 +182,7 @@ export class BotManager {
logger: this.logger, logger: this.logger,
}); });
this.bots.set(id, bot); this.bots.set(id, bot);
this.emit("botInstance", bot);
await bot.connect(); await bot.connect();
// Mark as autoStart so it reconnects on Docker restart, and persist identity // Mark as autoStart so it reconnects on Docker restart, and persist identity
this.database.saveBotInstance({ ...saved, autoStart: true }); this.database.saveBotInstance({ ...saved, autoStart: true });
@@ -229,6 +233,7 @@ export class BotManager {
}); });
this.bots.set(saved.id, bot); this.bots.set(saved.id, bot);
this.emit("botInstance", bot);
// Only auto-connect bots that have autoStart enabled // Only auto-connect bots that have autoStart enabled
if (saved.autoStart) { if (saved.autoStart) {
+27 -12
View File
@@ -10,8 +10,9 @@ export function setupWebSocket(
): () => void { ): () => void {
const clients = new Set<WebSocket>(); const clients = new Set<WebSocket>();
/** Track which bots already have listeners attached */ /** Track which bot instances have listeners attached (keyed by id, storing ref) */
const attachedBots = new Map<string, { const attachedBots = new Map<string, {
bot: BotInstance;
stateChange: () => void; stateChange: () => void;
connected: () => void; connected: () => void;
disconnected: () => 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 { 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 = () => { const onStateChange = () => {
broadcast({ broadcast({
@@ -81,6 +96,7 @@ export function setupWebSocket(
bot.on("disconnected", onDisconnected); bot.on("disconnected", onDisconnected);
attachedBots.set(bot.id, { attachedBots.set(bot.id, {
bot,
stateChange: onStateChange, stateChange: onStateChange,
connected: onConnected, connected: onConnected,
disconnected: onDisconnected, 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); const intervalId = setInterval(ensureAllBotsAttached, 5000);
ensureAllBotsAttached(); ensureAllBotsAttached();
return () => { return () => {
clearInterval(intervalId); clearInterval(intervalId);
// Clean up named listeners botManager.removeListener("botInstance", onBotInstance);
for (const bot of botManager.getAllBots()) { // Clean up all attached listeners (detach from stored bot refs, not live map)
const listeners = attachedBots.get(bot.id); for (const id of Array.from(attachedBots.keys())) {
if (listeners) { detachBotListener(id);
bot.removeListener("stateChange", listeners.stateChange);
bot.removeListener("connected", listeners.connected);
bot.removeListener("disconnected", listeners.disconnected);
}
} }
attachedBots.clear();
}; };
} }
+9 -9
View File
@@ -158,13 +158,13 @@ onUnmounted(() => {
.bot-selector-btn { .bot-selector-btn {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
padding: 8px 16px; padding: 10px 20px;
background: var(--hover-bg); background: var(--hover-bg);
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-size: 14px; font-size: 16px;
font-weight: 600; font-weight: 600;
min-height: 36px; min-height: 44px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
transition: background var(--transition-fast), border-color var(--transition-fast); transition: background var(--transition-fast), border-color var(--transition-fast);
cursor: pointer; cursor: pointer;
@@ -176,13 +176,13 @@ onUnmounted(() => {
} }
.bot-state-mini { .bot-state-mini {
font-size: 11px; font-size: 14px;
&.playing { color: #22c55e; } &.playing { color: #22c55e; }
&.paused { color: #eab308; } &.paused { color: #eab308; }
} }
.bot-chevron { .bot-chevron {
font-size: 16px; font-size: 20px;
opacity: 0.5; opacity: 0.5;
transition: transform 0.2s ease; transition: transform 0.2s ease;
@@ -192,8 +192,8 @@ onUnmounted(() => {
} }
.bot-dot { .bot-dot {
width: 8px; width: 10px;
height: 8px; height: 10px;
border-radius: 50%; border-radius: 50%;
background: var(--text-tertiary); background: var(--text-tertiary);
flex-shrink: 0; flex-shrink: 0;
@@ -204,7 +204,7 @@ onUnmounted(() => {
} }
.bot-selector-name { .bot-selector-name {
max-width: 120px; max-width: 160px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;