Tie playback to the listener and keep the panel's view live

Four things people hit while using the panel:

- The bot could be sent into a channel the requester was not in, and
  playback could be started from nowhere. Playback now follows the
  listener (REQUIRE_LISTENER, on by default), and the voice row shows
  where you and the bot are instead of offering a free channel picker.
- Voice presence only refreshed on reload, because the SDK updates
  channel participants without emitting an event. The socket now watches
  that view and pushes changes.
- The bot left the channel whenever the queue ran dry. It now leaves only
  after the last person does, EMPTY_TIMEOUT_SECONDS later (120 by
  default), and stays put while anyone is still listening.
- A search that yielded nothing said nothing: yt-dlp can exit 0 with an
  empty result, so that case now reports the reason (or "nothing found"),
  and searches are logged with their result count.

The queue moved under the player so search owns the left column, and
elapsed time no longer renders as "LIVE" — formatDuration treated 0 as a
live stream, which also affected the chat's progress bar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-09-08 23:59:17 +03:00
co-authored by Claude Opus 5
parent f22b08b350
commit 3e53f34374
16 changed files with 1819 additions and 1657 deletions
+229 -223
View File
@@ -1,223 +1,229 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "./api";
import { Login } from "./components/Login";
import { NowPlaying } from "./components/NowPlaying";
import { QueueList } from "./components/QueueList";
import { SearchPanel } from "./components/SearchPanel";
import type { Me, PlayerState, ServerStateResponse, VoiceChannel } from "./types";
const SERVER_KEY = "mbot.server";
export function App() {
const [me, setMe] = useState<Me | null>(null);
const [loading, setLoading] = useState(true);
const [serverId, setServerId] = useState<string | null>(null);
const [state, setState] = useState<PlayerState | null>(null);
const [position, setPosition] = useState(0);
const [voiceChannels, setVoiceChannels] = useState<VoiceChannel[]>([]);
const [yourVoiceChannel, setYourVoiceChannel] = useState<VoiceChannel | null>(null);
const [canControl, setCanControl] = useState(false);
const [error, setError] = useState<string | null>(null);
const socketRef = useRef<WebSocket | null>(null);
/** Consumes the one-time link issued by the `!panel` chat command. */
const consumeLinkToken = useCallback(async (): Promise<string | null> => {
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
if (!token) return null;
try {
const result = await api.loginWithLink(token);
window.history.replaceState({}, "", "/");
return result.serverId;
} catch (err) {
setError(err instanceof Error ? err.message : "Ссылка недействительна");
window.history.replaceState({}, "", "/");
return null;
}
}, []);
const loadMe = useCallback(
async (preferredServer?: string | null) => {
try {
const profile = await api.me();
setMe(profile);
const stored = preferredServer ?? localStorage.getItem(SERVER_KEY);
const chosen = profile.servers.find((server) => server.id === stored) ?? profile.servers[0];
setServerId(chosen?.id ?? null);
} catch {
setMe(null);
} finally {
setLoading(false);
}
},
[],
);
useEffect(() => {
void (async () => {
const fromLink = await consumeLinkToken();
await loadMe(fromLink);
})();
}, [consumeLinkToken, loadMe]);
const applyServerState = useCallback((payload: ServerStateResponse) => {
setState(payload.state);
setPosition(payload.state.position);
setVoiceChannels(payload.voiceChannels);
setYourVoiceChannel(payload.yourVoiceChannel);
setCanControl(payload.canControl);
}, []);
const refresh = useCallback(
async (id: string) => {
try {
applyServerState(await api.state(id));
} catch (err) {
setError(err instanceof Error ? err.message : "Не удалось получить состояние");
}
},
[applyServerState],
);
// Live updates: the socket carries both full snapshots and 1 Hz position ticks.
useEffect(() => {
if (!serverId) return;
localStorage.setItem(SERVER_KEY, serverId);
void refresh(serverId);
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocket(`${protocol}://${window.location.host}/ws?server=${serverId}`);
socketRef.current = socket;
socket.onmessage = (event) => {
const payload = JSON.parse(event.data as string) as
| { type: "state"; state: PlayerState }
| { type: "position"; position: number };
if (payload.type === "state") {
setState(payload.state);
setPosition(payload.state.position);
} else {
setPosition(payload.position);
}
};
return () => {
socket.close();
socketRef.current = null;
};
}, [serverId, refresh]);
const runAction = useCallback(
async (action: string, payload: Record<string, unknown> = {}) => {
if (!serverId) return;
setError(null);
try {
await api.action(serverId, action, payload);
} catch (err) {
setError(err instanceof Error ? err.message : "Действие не выполнено");
}
},
[serverId],
);
if (loading) return <div className="login-wrap">Загрузка</div>;
if (!me) return <Login onSuccess={() => void loadMe()} />;
return (
<div className="app">
<header className="topbar">
<div className="brand">
<span className="dot" />
Stoat Music
</div>
{me.servers.length > 0 && (
<select value={serverId ?? ""} onChange={(event) => setServerId(event.target.value)}>
{me.servers.map((server) => (
<option key={server.id} value={server.id}>
{server.name}
</option>
))}
</select>
)}
<div className="spacer" />
<span className="who">{me.user.username}</span>
<button
className="ghost"
onClick={async () => {
await api.logout();
setMe(null);
}}
>
Выйти
</button>
</header>
{error && <div className="error">{error}</div>}
{!serverId || !state ? (
<div className="card empty">
Бот не состоит ни в одном общем с вами сервере. Пригласите его и обновите страницу.
</div>
) : (
<div className="layout">
<div>
<SearchPanel
serverId={serverId}
canControl={canControl}
localLibrary={me.features.localLibrary}
onError={setError}
/>
<QueueList
tracks={state.queue}
canControl={canControl}
onRemove={(track) => {
void api.removeTrack(serverId, track.id).catch((err: Error) => setError(err.message));
}}
onMove={(track, index) => {
void api.moveTrack(serverId, track.id, index).catch((err: Error) => setError(err.message));
}}
onClear={() => void runAction("clear")}
/>
</div>
<div>
<NowPlaying
state={state}
position={position}
canControl={canControl}
voiceChannels={voiceChannels}
yourVoiceChannel={yourVoiceChannel}
onAction={(action, payload) => void runAction(action, payload)}
onSeek={(seconds) => void runAction("seek", { position: seconds })}
onJoin={(channelId) => {
void api
.join(serverId, channelId)
.then(() => refresh(serverId))
.catch((err: Error) => setError(err.message));
}}
/>
{state.history.length > 0 && (
<div className="card">
<h2>История</h2>
<ul className="track-list">
{state.history.map((track, index) => (
<li className="track" key={`${track.id}-${index}`}>
<span className="idx">{index + 1}</span>
<div className="info">
<div className="title">{track.title}</div>
<div className="sub">{track.author ?? ""}</div>
</div>
</li>
))}
</ul>
</div>
)}
</div>
</div>
)}
</div>
);
}
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "./api";
import { Login } from "./components/Login";
import { NowPlaying } from "./components/NowPlaying";
import { QueueList } from "./components/QueueList";
import { SearchPanel } from "./components/SearchPanel";
import type { Me, PlayerState, ServerStateResponse, VoiceChannel } from "./types";
const SERVER_KEY = "mbot.server";
export function App() {
const [me, setMe] = useState<Me | null>(null);
const [loading, setLoading] = useState(true);
const [serverId, setServerId] = useState<string | null>(null);
const [state, setState] = useState<PlayerState | null>(null);
const [position, setPosition] = useState(0);
const [voiceChannels, setVoiceChannels] = useState<VoiceChannel[]>([]);
const [yourVoiceChannel, setYourVoiceChannel] = useState<VoiceChannel | null>(null);
const [canControl, setCanControl] = useState(false);
const [error, setError] = useState<string | null>(null);
const socketRef = useRef<WebSocket | null>(null);
/** Consumes the one-time link issued by the `!panel` chat command. */
const consumeLinkToken = useCallback(async (): Promise<string | null> => {
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
if (!token) return null;
try {
const result = await api.loginWithLink(token);
window.history.replaceState({}, "", "/");
return result.serverId;
} catch (err) {
setError(err instanceof Error ? err.message : "Ссылка недействительна");
window.history.replaceState({}, "", "/");
return null;
}
}, []);
const loadMe = useCallback(
async (preferredServer?: string | null) => {
try {
const profile = await api.me();
setMe(profile);
const stored = preferredServer ?? localStorage.getItem(SERVER_KEY);
const chosen = profile.servers.find((server) => server.id === stored) ?? profile.servers[0];
setServerId(chosen?.id ?? null);
} catch {
setMe(null);
} finally {
setLoading(false);
}
},
[],
);
useEffect(() => {
void (async () => {
const fromLink = await consumeLinkToken();
await loadMe(fromLink);
})();
}, [consumeLinkToken, loadMe]);
const applyServerState = useCallback((payload: ServerStateResponse) => {
setState(payload.state);
setPosition(payload.state.position);
setVoiceChannels(payload.voiceChannels);
setYourVoiceChannel(payload.yourVoiceChannel);
setCanControl(payload.canControl);
}, []);
const refresh = useCallback(
async (id: string) => {
try {
applyServerState(await api.state(id));
} catch (err) {
setError(err instanceof Error ? err.message : "Не удалось получить состояние");
}
},
[applyServerState],
);
// Live updates: the socket carries both full snapshots and 1 Hz position ticks.
useEffect(() => {
if (!serverId) return;
localStorage.setItem(SERVER_KEY, serverId);
void refresh(serverId);
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocket(`${protocol}://${window.location.host}/ws?server=${serverId}`);
socketRef.current = socket;
socket.onmessage = (event) => {
const payload = JSON.parse(event.data as string) as
| { type: "state"; state: PlayerState }
| { type: "position"; position: number }
| { type: "presence"; yourVoiceChannel: VoiceChannel | null; voiceChannels: VoiceChannel[] };
if (payload.type === "state") {
setState(payload.state);
setPosition(payload.state.position);
} else if (payload.type === "presence") {
setYourVoiceChannel(payload.yourVoiceChannel);
setVoiceChannels(payload.voiceChannels);
} else {
setPosition(payload.position);
}
};
return () => {
socket.close();
socketRef.current = null;
};
}, [serverId, refresh]);
const runAction = useCallback(
async (action: string, payload: Record<string, unknown> = {}) => {
if (!serverId) return;
setError(null);
try {
await api.action(serverId, action, payload);
} catch (err) {
setError(err instanceof Error ? err.message : "Действие не выполнено");
}
},
[serverId],
);
if (loading) return <div className="login-wrap">Загрузка</div>;
if (!me) return <Login onSuccess={() => void loadMe()} />;
return (
<div className="app">
<header className="topbar">
<div className="brand">
<span className="dot" />
Stoat Music
</div>
{me.servers.length > 0 && (
<select value={serverId ?? ""} onChange={(event) => setServerId(event.target.value)}>
{me.servers.map((server) => (
<option key={server.id} value={server.id}>
{server.name}
</option>
))}
</select>
)}
<div className="spacer" />
<span className="who">{me.user.username}</span>
<button
className="ghost"
onClick={async () => {
await api.logout();
setMe(null);
}}
>
Выйти
</button>
</header>
{error && <div className="error">{error}</div>}
{!serverId || !state ? (
<div className="card empty">
Бот не состоит ни в одном общем с вами сервере. Пригласите его и обновите страницу.
</div>
) : (
<div className="layout">
<div>
<SearchPanel
serverId={serverId}
canControl={canControl}
localLibrary={me.features.localLibrary}
onError={setError}
/>
</div>
<div>
<NowPlaying
state={state}
position={position}
canControl={canControl}
voiceChannels={voiceChannels}
yourVoiceChannel={yourVoiceChannel}
requireListener={me.features.requireListener}
onAction={(action, payload) => void runAction(action, payload)}
onSeek={(seconds) => void runAction("seek", { position: seconds })}
onJoin={(channelId) => {
void api
.join(serverId, channelId)
.then(() => refresh(serverId))
.catch((err: Error) => setError(err.message));
}}
/>
<QueueList
tracks={state.queue}
canControl={canControl}
onRemove={(track) => {
void api.removeTrack(serverId, track.id).catch((err: Error) => setError(err.message));
}}
onMove={(track, index) => {
void api.moveTrack(serverId, track.id, index).catch((err: Error) => setError(err.message));
}}
onClear={() => void runAction("clear")}
/>
{state.history.length > 0 && (
<div className="card">
<h2>История</h2>
<ul className="track-list">
{state.history.map((track, index) => (
<li className="track" key={`${track.id}-${index}`}>
<span className="idx">{index + 1}</span>
<div className="info">
<div className="title">{track.title}</div>
<div className="sub">{track.author ?? ""}</div>
</div>
</li>
))}
</ul>
</div>
)}
</div>
</div>
)}
</div>
);
}