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
+8 -2
View File
@@ -51,8 +51,10 @@ DEFAULT_VOLUME=60
MAX_QUEUE_SIZE=500 MAX_QUEUE_SIZE=500
SEARCH_RESULT_LIMIT=10 SEARCH_RESULT_LIMIT=10
# Через сколько секунд простоя бот выходит из голосового канала (0 — никогда). # Через сколько секунд после ухода ПОСЛЕДНЕГО человека бот покидает голосовой
IDLE_TIMEOUT_SECONDS=300 # канал (0 — не выходить никогда). Пока в канале кто-то есть, бот остаётся,
# даже если очередь давно закончилась.
EMPTY_TIMEOUT_SECONDS=120
# --------------------------------------------------------------- Доступ ---- # --------------------------------------------------------------- Доступ ----
# false — управлять может любой участник сервера. # false — управлять может любой участник сервера.
@@ -60,6 +62,10 @@ IDLE_TIMEOUT_SECONDS=300
REQUIRE_DJ_ROLE=false REQUIRE_DJ_ROLE=false
DJ_ROLE_NAME=DJ DJ_ROLE_NAME=DJ
# true — позвать бота можно только в тот голосовой канал, где вы сами находитесь
# (и панель, и команды). false — разрешить выбирать канал вручную.
REQUIRE_LISTENER=true
# ----------------------------------------------------------------- Прочее --- # ----------------------------------------------------------------- Прочее ---
LOG_LEVEL=info LOG_LEVEL=info
NODE_ENV=production NODE_ENV=production
+6 -1
View File
@@ -181,9 +181,14 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f
засорять канал. Для этого роли бота нужно право **Manage Messages** в настройках сервера засорять канал. Для этого роли бота нужно право **Manage Messages** в настройках сервера
(Settings → Roles → роль бота) или в правах самого канала. Без него команда всё равно (Settings → Roles → роль бота) или в правах самого канала. Без него команда всё равно
отработает, а в логе будет `could not delete command message` с причиной отказа. отработает, а в логе будет `could not delete command message` с причиной отказа.
- `REQUIRE_LISTENER=true` (по умолчанию) — позвать бота можно только в тот голосовой канал, где
вы сами сидите: музыка идёт за слушателем, отправить бота «куда-то ещё» из панели нельзя.
Поставьте `false`, если хотите выбирать канал вручную.
- `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer` - `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer`
и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера. и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера.
- `IDLE_TIMEOUT_SECONDS` — через сколько секунд простоя (или пустого канала) бот выходит из войса. - `EMPTY_TIMEOUT_SECONDS` — через сколько секунд после ухода последнего человека бот покидает
голосовой канал (по умолчанию 120, `0` — не выходить никогда). Пустая очередь поводом уйти
не считается: пока в канале кто-то есть, бот ждёт следующий трек.
- `LOCAL_MEDIA_DIR` — примонтируйте том с музыкой и укажите путь внутри контейнера, - `LOCAL_MEDIA_DIR` — примонтируйте том с музыкой и укажите путь внутри контейнера,
тогда заработают `local:` и поиск по медиатеке. тогда заработают `local:` и поиск по медиатеке.
- `YTDLP_COOKIES` — путь к `cookies.txt`, см. раздел ниже. - `YTDLP_COOKIES` — путь к `cookies.txt`, см. раздел ниже.
+22 -1
View File
@@ -183,7 +183,10 @@ export async function startApiServer({ manager, context }: ApiServerOptions) {
return reply.send({ return reply.send({
user: { id: session.userId, username: session.username }, user: { id: session.userId, username: session.username },
servers, servers,
features: { localLibrary: Boolean(config.LOCAL_MEDIA_DIR) }, features: {
localLibrary: Boolean(config.LOCAL_MEDIA_DIR),
requireListener: config.REQUIRE_LISTENER,
},
}); });
}); });
@@ -334,7 +337,25 @@ export async function startApiServer({ manager, context }: ApiServerOptions) {
subscribers.set(serverId, listeners); subscribers.set(serverId, listeners);
socket.send(JSON.stringify({ type: "state", state: manager.snapshot(serverId) })); socket.send(JSON.stringify({ type: "state", state: manager.snapshot(serverId) }));
// Stoat's SDK keeps voice participants up to date but emits no event for
// them, so we watch our own view and push when this viewer's presence
// changes — otherwise the panel only learns about it on reload.
let lastPresence = "";
const sendPresence = () => {
const yourVoiceChannel = context.findUserVoiceChannel(serverId, session.userId);
const voiceChannels = context.listVoiceChannels(serverId);
const fingerprint = JSON.stringify([yourVoiceChannel, voiceChannels]);
if (fingerprint === lastPresence) return;
lastPresence = fingerprint;
socket.send(JSON.stringify({ type: "presence", yourVoiceChannel, voiceChannels }));
};
sendPresence();
const presenceTimer = setInterval(sendPresence, 3000);
presenceTimer.unref?.();
socket.on("close", () => { socket.on("close", () => {
clearInterval(presenceTimer);
listeners.delete(socket); listeners.delete(socket);
if (listeners.size === 0) subscribers.delete(serverId); if (listeners.size === 0) subscribers.delete(serverId);
}); });
+10 -2
View File
@@ -1,7 +1,8 @@
import type { LoopMode, Track } from "../types.js"; import type { LoopMode, Track } from "../types.js";
/** Clock formatting for any position or length; 0 is a legitimate "0:00". */
export function formatDuration(seconds: number): string { export function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return "LIVE"; if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const total = Math.floor(seconds); const total = Math.floor(seconds);
const hours = Math.floor(total / 3600); const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60); const minutes = Math.floor((total % 3600) / 60);
@@ -10,6 +11,13 @@ export function formatDuration(seconds: number): string {
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`; return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`;
} }
/** Track length as shown to people: live streams and unknown lengths are not clocks. */
export function formatLength(track: { duration: number; isLive: boolean }): string {
if (track.isLive) return "LIVE";
if (track.duration <= 0) return "—";
return formatDuration(track.duration);
}
export function parseTimecode(input: string): number | null { export function parseTimecode(input: string): number | null {
const trimmed = input.trim(); const trimmed = input.trim();
if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10); if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10);
@@ -42,7 +50,7 @@ export function trackLine(track: Track, index?: number): string {
const prefix = index === undefined ? "" : `**${index}.** `; const prefix = index === undefined ? "" : `**${index}.** `;
const author = track.author ? `${track.author}` : ""; const author = track.author ? `${track.author}` : "";
const link = /^https?:/i.test(track.url) ? `[${track.title}](${track.url})` : track.title; const link = /^https?:/i.test(track.url) ? `[${track.title}](${track.url})` : track.title;
return `${prefix}${link}${author} \`[${formatDuration(track.duration)}]\` · ${SOURCE_LABEL[track.source]} · <@${track.requestedBy.id}>`; return `${prefix}${link}${author} \`[${formatLength(track)}]\` · ${SOURCE_LABEL[track.source]} · <@${track.requestedBy.id}>`;
} }
export function loopLabel(mode: LoopMode): string { export function loopLabel(mode: LoopMode): string {
+7 -1
View File
@@ -50,8 +50,14 @@ const schema = z.object({
DEFAULT_VOLUME: z.coerce.number().min(0).max(200).default(60), DEFAULT_VOLUME: z.coerce.number().min(0).max(200).default(60),
MAX_QUEUE_SIZE: z.coerce.number().int().positive().default(500), MAX_QUEUE_SIZE: z.coerce.number().int().positive().default(500),
SEARCH_RESULT_LIMIT: z.coerce.number().int().positive().max(25).default(10), SEARCH_RESULT_LIMIT: z.coerce.number().int().positive().max(25).default(10),
IDLE_TIMEOUT_SECONDS: z.coerce.number().int().min(0).default(300), /** Seconds to wait after the last human leaves the voice channel (0 — never leave). */
EMPTY_TIMEOUT_SECONDS: z.coerce.number().int().min(0).default(120),
DJ_ROLE_NAME: z.string().default("DJ"), DJ_ROLE_NAME: z.string().default("DJ"),
/** Only let people summon the bot into the voice channel they are sitting in. */
REQUIRE_LISTENER: z
.enum(["true", "false"])
.default("true")
.transform((value) => value === "true"),
REQUIRE_DJ_ROLE: z REQUIRE_DJ_ROLE: z
.enum(["true", "false"]) .enum(["true", "false"])
.default("false") .default("false")
+16 -4
View File
@@ -141,10 +141,22 @@ export class MusicManager extends EventEmitter<ManagerEvents> {
const player = this.getOrCreate(serverId); const player = this.getOrCreate(serverId);
if (options.textChannelId) player.textChannelId = options.textChannelId; if (options.textChannelId) player.textChannelId = options.textChannelId;
const target = options.voiceChannelId const listening = this.chat.findUserVoiceChannel(serverId, userId);
? this.chat.getVoiceChannel(options.voiceChannelId)
: (this.chat.findUserVoiceChannel(serverId, userId) ?? if (config.REQUIRE_LISTENER) {
(player.voiceChannelId ? this.chat.getVoiceChannel(player.voiceChannelId) : null)); // Music follows the listener: you cannot push the bot into a channel you
// are not sitting in, and you cannot start playback from nowhere.
if (!listening) throw new UserFacingError("Сначала зайдите в голосовой канал");
if (options.voiceChannelId && options.voiceChannelId !== listening.id) {
throw new UserFacingError("Бота можно позвать только в тот канал, где вы находитесь");
}
}
const target = config.REQUIRE_LISTENER
? listening
: (options.voiceChannelId
? this.chat.getVoiceChannel(options.voiceChannelId)
: (listening ?? (player.voiceChannelId ? this.chat.getVoiceChannel(player.voiceChannelId) : null)));
if (!target) { if (!target) {
throw new UserFacingError("Зайдите в голосовой канал или укажите его явно"); throw new UserFacingError("Зайдите в голосовой канал или укажите его явно");
+26 -20
View File
@@ -77,7 +77,7 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
private seekOffset = 0; private seekOffset = 0;
/** Set while we tear playback down ourselves, so the resulting `finish` is ignored. */ /** Set while we tear playback down ourselves, so the resulting `finish` is ignored. */
private expectingStop = false; private expectingStop = false;
private idleTimer: NodeJS.Timeout | null = null; private leaveTimer: NodeJS.Timeout | null = null;
private ticker: NodeJS.Timeout | null = null; private ticker: NodeJS.Timeout | null = null;
private readonly log; private readonly log;
@@ -182,7 +182,7 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
}); });
connection.on("userleave", () => this.checkEmptyChannel()); connection.on("userleave", () => this.checkEmptyChannel());
connection.on("userLeave", () => this.checkEmptyChannel()); connection.on("userLeave", () => this.checkEmptyChannel());
connection.on("userJoin", () => this.clearIdleTimer()); connection.on("userJoin", () => this.cancelLeaveTimer());
const media = new MediaPlayer(true); const media = new MediaPlayer(true);
// revoice's #cleanUp() dereferences this.fProc unconditionally, so a second // revoice's #cleanUp() dereferences this.fProc unconditionally, so a second
@@ -211,11 +211,12 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
await connection.play(media); await connection.play(media);
this.setStatus("idle"); this.setStatus("idle");
this.checkEmptyChannel();
this.log.info({ channelId }, "voice connection established"); this.log.info({ channelId }, "voice connection established");
} }
async leaveVoice(): Promise<void> { async leaveVoice(): Promise<void> {
this.clearIdleTimer(); this.cancelLeaveTimer();
this.stopTicker(); this.stopTicker();
this.teardownPlayback(); this.teardownPlayback();
this.current = null; this.current = null;
@@ -242,8 +243,11 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
private checkEmptyChannel(): void { private checkEmptyChannel(): void {
if (!this.connection) return; if (!this.connection) return;
if (this.connection.getUsers().length > 0) return; if (this.connection.getUsers().length > 0) {
this.startIdleTimer("В канале никого не осталось"); this.cancelLeaveTimer();
return;
}
this.startLeaveTimer();
} }
// -------------------------------------------------------------- playback --- // -------------------------------------------------------------- playback ---
@@ -272,7 +276,7 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
private async startPlayback(track: Track, seekSeconds = 0): Promise<void> { private async startPlayback(track: Track, seekSeconds = 0): Promise<void> {
const media = this.assertReady(); const media = this.assertReady();
this.clearIdleTimer(); this.cancelLeaveTimer();
this.teardownPlayback(); this.teardownPlayback();
this.current = track; this.current = track;
@@ -361,7 +365,6 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
this.setStatus("idle"); this.setStatus("idle");
this.publish(); this.publish();
if (finished) this.notify("⏹️ Очередь закончилась."); if (finished) this.notify("⏹️ Очередь закончилась.");
this.startIdleTimer();
return; return;
} }
@@ -385,7 +388,6 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
this.stopTicker(); this.stopTicker();
this.setStatus("idle"); this.setStatus("idle");
this.publish(); this.publish();
this.startIdleTimer();
} }
pause(): void { pause(): void {
@@ -501,21 +503,25 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
this.ticker = null; this.ticker = null;
} }
private clearIdleTimer(): void { private cancelLeaveTimer(): void {
if (!this.idleTimer) return; if (!this.leaveTimer) return;
clearTimeout(this.idleTimer); clearTimeout(this.leaveTimer);
this.idleTimer = null; this.leaveTimer = null;
} }
private startIdleTimer(reason?: string): void { /**
this.clearIdleTimer(); * Leaving is tied to the channel being empty, never to an idle queue: the bot
if (config.IDLE_TIMEOUT_SECONDS <= 0 || !this.connection) return; * stays put with people around, waiting for the next request.
this.idleTimer = setTimeout(() => { */
if (this.current) return; private startLeaveTimer(): void {
this.notify(`👋 ${reason ?? "Нет активности"}, выхожу из голосового канала.`); this.cancelLeaveTimer();
if (config.EMPTY_TIMEOUT_SECONDS <= 0 || !this.connection) return;
this.leaveTimer = setTimeout(() => {
if (this.connection && this.connection.getUsers().length > 0) return;
this.notify("👋 В канале никого не осталось, выхожу.");
void this.leaveVoice(); void this.leaveVoice();
}, config.IDLE_TIMEOUT_SECONDS * 1000); }, config.EMPTY_TIMEOUT_SECONDS * 1000);
this.idleTimer.unref?.(); this.leaveTimer.unref?.();
} }
async destroy(): Promise<void> { async destroy(): Promise<void> {
+6
View File
@@ -16,6 +16,12 @@ async function main(): Promise<void> {
); );
} }
if (process.env["IDLE_TIMEOUT_SECONDS"]) {
logger.warn(
"IDLE_TIMEOUT_SECONDS is gone: the bot now leaves only when the voice channel empties — use EMPTY_TIMEOUT_SECONDS",
);
}
const cookies = await checkCookies(); const cookies = await checkCookies();
if (cookies === "ok") { if (cookies === "ok") {
logger.info({ path: config.YTDLP_COOKIES }, "using YouTube cookies"); logger.info({ path: config.YTDLP_COOKIES }, "using YouTube cookies");
+23 -9
View File
@@ -61,8 +61,14 @@ export async function checkCookies(): Promise<CookieStatus | null> {
} }
} }
function runYtDlp(args: string[], timeoutMs = 45_000): Promise<string> { interface YtDlpRun {
return new Promise((resolve, reject) => { stdout: string;
stderr: string;
code: number | null;
}
function runYtDlp(args: string[], timeoutMs = 45_000): Promise<YtDlpRun> {
return new Promise<YtDlpRun>((resolve, reject) => {
const child = spawn(config.YTDLP_PATH, args, { windowsHide: true }); const child = spawn(config.YTDLP_PATH, args, { windowsHide: true });
let stdout = ""; let stdout = "";
let stderr = ""; let stderr = "";
@@ -88,7 +94,7 @@ function runYtDlp(args: string[], timeoutMs = 45_000): Promise<string> {
child.on("close", (code) => { child.on("close", (code) => {
clearTimeout(timer); clearTimeout(timer);
if (code === 0 || stdout.trim().length > 0) { if (code === 0 || stdout.trim().length > 0) {
resolve(stdout); resolve({ stdout, stderr, code });
return; return;
} }
log.warn({ code, stderr: stderr.slice(0, 800) }, "yt-dlp failed"); log.warn({ code, stderr: stderr.slice(0, 800) }, "yt-dlp failed");
@@ -160,18 +166,26 @@ export async function search(
requestedBy: Requester, requestedBy: Requester,
): Promise<Track[]> { ): Promise<Track[]> {
const prefix = kind === "soundcloud" ? "scsearch" : "ytsearch"; const prefix = kind === "soundcloud" ? "scsearch" : "ytsearch";
const stdout = await runYtDlp([ const { stdout, stderr } = await runYtDlp([
...baseArgs(), ...baseArgs(),
"--flat-playlist", "--flat-playlist",
"--dump-json", "--dump-json",
`${prefix}${limit}:${query}`, `${prefix}${limit}:${query}`,
]); ]);
return parseNdjson(stdout).map((entry) => toTrack(entry, requestedBy, kind)); const entries = parseNdjson(stdout);
// yt-dlp can exit 0 with nothing to show (bot checks, region blocks). Without
// this the panel would just render an empty list and say nothing at all.
if (entries.length === 0 && stderr.trim()) {
log.warn({ query, kind, stderr: stderr.slice(0, 500) }, "search returned nothing");
throw new UserFacingError(firstUsefulError(stderr));
}
log.info({ query, kind, count: entries.length }, "search");
return entries.map((entry) => toTrack(entry, requestedBy, kind));
} }
/** Resolves a URL that may point at a single track, a playlist, or an album. */ /** Resolves a URL that may point at a single track, a playlist, or an album. */
export async function resolveUrl(url: string, requestedBy: Requester, maxTracks: number): Promise<SearchResult> { export async function resolveUrl(url: string, requestedBy: Requester, maxTracks: number): Promise<SearchResult> {
const stdout = await runYtDlp([ const { stdout } = await runYtDlp([
...baseArgs(), ...baseArgs(),
"--flat-playlist", "--flat-playlist",
"--dump-single-json", "--dump-single-json",
@@ -200,7 +214,7 @@ export async function resolveUrl(url: string, requestedBy: Requester, maxTracks:
/** Resolves a direct, time-limited media URL for a page URL (used when seeking). */ /** Resolves a direct, time-limited media URL for a page URL (used when seeking). */
export async function resolveStreamUrl(pageUrl: string): Promise<string> { export async function resolveStreamUrl(pageUrl: string): Promise<string> {
const stdout = await runYtDlp([...baseArgs(), "-f", "bestaudio/best", "--no-playlist", "-g", pageUrl]); const { stdout } = await runYtDlp([...baseArgs(), "-f", "bestaudio/best", "--no-playlist", "-g", pageUrl]);
const url = stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean); const url = stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
if (!url) throw new UserFacingError("Не удалось получить ссылку на аудиопоток"); if (!url) throw new UserFacingError("Не удалось получить ссылку на аудиопоток");
return url; return url;
@@ -252,8 +266,8 @@ export function openAudioStream(pageUrl: string): AudioProcess {
export async function checkAvailable(): Promise<string | null> { export async function checkAvailable(): Promise<string | null> {
try { try {
const out = await runYtDlp(["--version"], 15_000); const { stdout } = await runYtDlp(["--version"], 15_000);
return out.trim() || null; return stdout.trim() || null;
} catch { } catch {
return null; return null;
} }
+25 -19
View File
@@ -92,10 +92,14 @@ export function App() {
socket.onmessage = (event) => { socket.onmessage = (event) => {
const payload = JSON.parse(event.data as string) as const payload = JSON.parse(event.data as string) as
| { type: "state"; state: PlayerState } | { type: "state"; state: PlayerState }
| { type: "position"; position: number }; | { type: "position"; position: number }
| { type: "presence"; yourVoiceChannel: VoiceChannel | null; voiceChannels: VoiceChannel[] };
if (payload.type === "state") { if (payload.type === "state") {
setState(payload.state); setState(payload.state);
setPosition(payload.state.position); setPosition(payload.state.position);
} else if (payload.type === "presence") {
setYourVoiceChannel(payload.yourVoiceChannel);
setVoiceChannels(payload.voiceChannels);
} else { } else {
setPosition(payload.position); setPosition(payload.position);
} }
@@ -169,6 +173,26 @@ export function App() {
localLibrary={me.features.localLibrary} localLibrary={me.features.localLibrary}
onError={setError} 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 <QueueList
tracks={state.queue} tracks={state.queue}
canControl={canControl} canControl={canControl}
@@ -180,24 +204,6 @@ export function App() {
}} }}
onClear={() => void runAction("clear")} 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 && ( {state.history.length > 0 && (
<div className="card"> <div className="card">
+9 -1
View File
@@ -72,8 +72,9 @@ export const api = {
}), }),
}; };
/** Clock formatting for any position or length; 0 is a legitimate "0:00". */
export function formatDuration(seconds: number): string { export function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return "LIVE"; if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const total = Math.floor(seconds); const total = Math.floor(seconds);
const hours = Math.floor(total / 3600); const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60); const minutes = Math.floor((total % 3600) / 60);
@@ -81,3 +82,10 @@ export function formatDuration(seconds: number): string {
const pad = (value: number) => value.toString().padStart(2, "0"); const pad = (value: number) => value.toString().padStart(2, "0");
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`; return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`;
} }
/** Track length as shown to people: live streams and unknown lengths are not clocks. */
export function formatLength(track: { duration: number; isLive: boolean }): string {
if (track.isLive) return "LIVE";
if (track.duration <= 0) return "—";
return formatDuration(track.duration);
}
+37 -14
View File
@@ -1,5 +1,5 @@
import { useEffect, useState, type MouseEvent } from "react"; import { useEffect, useState, type MouseEvent } from "react";
import { formatDuration } from "../api"; import { formatDuration, formatLength } from "../api";
import type { LoopMode, PlayerState, VoiceChannel } from "../types"; import type { LoopMode, PlayerState, VoiceChannel } from "../types";
interface Props { interface Props {
@@ -8,6 +8,7 @@ interface Props {
canControl: boolean; canControl: boolean;
voiceChannels: VoiceChannel[]; voiceChannels: VoiceChannel[];
yourVoiceChannel: VoiceChannel | null; yourVoiceChannel: VoiceChannel | null;
requireListener: boolean;
onAction(action: string, payload?: Record<string, unknown>): void; onAction(action: string, payload?: Record<string, unknown>): void;
onSeek(seconds: number): void; onSeek(seconds: number): void;
onJoin(channelId: string | null): void; onJoin(channelId: string | null): void;
@@ -33,6 +34,7 @@ export function NowPlaying({
canControl, canControl,
voiceChannels, voiceChannels,
yourVoiceChannel, yourVoiceChannel,
requireListener,
onAction, onAction,
onSeek, onSeek,
onJoin, onJoin,
@@ -100,8 +102,8 @@ export function NowPlaying({
<span style={{ width: `${ratio * 100}%` }} /> <span style={{ width: `${ratio * 100}%` }} />
</div> </div>
<div className="times"> <div className="times">
<span>{track ? formatDuration(position) : "0:00"}</span> <span>{formatDuration(track ? position : 0)}</span>
<span>{track?.isLive ? "LIVE" : formatDuration(duration)}</span> <span>{track ? formatLength(track) : "0:00"}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -156,17 +158,38 @@ export function NowPlaying({
</div> </div>
<div className="voice-row"> <div className="voice-row">
<select value={channelId} onChange={(event) => setChannelId(event.target.value)} disabled={!canControl}> {requireListener ? (
{voiceChannels.length === 0 && <option value="">Нет голосовых каналов</option>} // Playback follows the listener, so there is nothing to choose here:
{voiceChannels.map((channel) => ( // the bot joins the channel you are sitting in.
<option key={channel.id} value={channel.id}> <span className="voice-status">
{channel.name} {yourVoiceChannel ? (
{yourVoiceChannel?.id === channel.id ? " (вы здесь)" : ""} <>
</option> Вы в канале <strong>{yourVoiceChannel.name}</strong>
))} </>
</select> ) : (
<button onClick={() => onJoin(channelId || null)} disabled={!canControl || !channelId}> "Вы не в голосовом канале"
{state.voiceChannelId === channelId ? "Переподключить" : "Зайти"} )}
{state.voiceChannelName ? ` · бот в «${state.voiceChannelName}»` : " · бот не в канале"}
</span>
) : (
<select value={channelId} onChange={(event) => setChannelId(event.target.value)} disabled={!canControl}>
{voiceChannels.length === 0 && <option value="">Нет голосовых каналов</option>}
{voiceChannels.map((channel) => (
<option key={channel.id} value={channel.id}>
{channel.name}
{yourVoiceChannel?.id === channel.id ? " (вы здесь)" : ""}
</option>
))}
</select>
)}
<button
onClick={() => onJoin(requireListener ? null : channelId || null)}
disabled={!canControl || (requireListener ? !yourVoiceChannel : !channelId)}
title={requireListener && !yourVoiceChannel ? "Сначала зайдите в голосовой канал" : undefined}
>
{state.voiceChannelId && state.voiceChannelId === (requireListener ? yourVoiceChannel?.id : channelId)
? "Переподключить"
: "Позвать"}
</button> </button>
<button onClick={() => onAction("leave")} disabled={!canControl || !state.voiceChannelId}> <button onClick={() => onAction("leave")} disabled={!canControl || !state.voiceChannelId}>
Выйти Выйти
+2 -4
View File
@@ -1,4 +1,4 @@
import { formatDuration } from "../api"; import { formatDuration, formatLength } from "../api";
import type { Track } from "../types"; import type { Track } from "../types";
interface Props { interface Props {
@@ -31,9 +31,7 @@ export function QueueList({ tracks, canControl, onRemove, onMove, onClear }: Pro
<div className="info"> <div className="info">
<div className="title">{track.title}</div> <div className="title">{track.title}</div>
<div className="sub"> <div className="sub">
{[track.author, track.isLive ? "LIVE" : formatDuration(track.duration), track.requestedBy.username] {[track.author, formatLength(track), track.requestedBy.username].filter(Boolean).join(" · ")}
.filter(Boolean)
.join(" · ")}
</div> </div>
</div> </div>
<div className="actions"> <div className="actions">
+11 -4
View File
@@ -1,5 +1,5 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { api, formatDuration } from "../api"; import { api, formatLength } from "../api";
import type { Track } from "../types"; import type { Track } from "../types";
const SOURCE_BADGE: Record<Track["source"], string> = { const SOURCE_BADGE: Record<Track["source"], string> = {
@@ -21,6 +21,7 @@ export function SearchPanel({ serverId, canControl, localLibrary, onError }: Pro
const [results, setResults] = useState<Track[]>([]); const [results, setResults] = useState<Track[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [lastAdded, setLastAdded] = useState<string | null>(null); const [lastAdded, setLastAdded] = useState<string | null>(null);
const [searched, setSearched] = useState(false);
async function submit(event: FormEvent) { async function submit(event: FormEvent) {
event.preventDefault(); event.preventDefault();
@@ -33,11 +34,13 @@ export function SearchPanel({ serverId, canControl, localLibrary, onError }: Pro
await api.play(serverId, { query: value }); await api.play(serverId, { query: value });
setLastAdded(value); setLastAdded(value);
setResults([]); setResults([]);
setSearched(false);
setQuery(""); setQuery("");
return; return;
} }
const { tracks } = await api.search(serverId, value); const { tracks } = await api.search(serverId, value);
setResults(tracks); setResults(tracks);
setSearched(true);
} catch (err) { } catch (err) {
onError(err instanceof Error ? err.message : "Поиск не удался"); onError(err instanceof Error ? err.message : "Поиск не удался");
} finally { } finally {
@@ -56,7 +59,7 @@ export function SearchPanel({ serverId, canControl, localLibrary, onError }: Pro
} }
return ( return (
<div className="card"> <div className="card grow">
<h2>Поиск</h2> <h2>Поиск</h2>
<form className="search-form" onSubmit={submit}> <form className="search-form" onSubmit={submit}>
<input <input
@@ -72,7 +75,11 @@ export function SearchPanel({ serverId, canControl, localLibrary, onError }: Pro
{results.length === 0 ? ( {results.length === 0 ? (
<div className="empty"> <div className="empty">
{lastAdded ? `Добавлено: ${lastAdded}` : "Введите запрос или вставьте ссылку — плейлисты тоже поддерживаются."} {searched
? `По запросу «${query}» ничего не нашлось`
: lastAdded
? `Добавлено: ${lastAdded}`
: "Введите запрос или вставьте ссылку — плейлисты тоже поддерживаются."}
</div> </div>
) : ( ) : (
<ul className="track-list"> <ul className="track-list">
@@ -83,7 +90,7 @@ export function SearchPanel({ serverId, canControl, localLibrary, onError }: Pro
<div className="info"> <div className="info">
<div className="title">{track.title}</div> <div className="title">{track.title}</div>
<div className="sub"> <div className="sub">
{[track.author, track.isLive ? "LIVE" : formatDuration(track.duration)].filter(Boolean).join(" · ")} {[track.author, formatLength(track)].filter(Boolean).join(" · ")}
</div> </div>
</div> </div>
<span className="badge">{SOURCE_BADGE[track.source]}</span> <span className="badge">{SOURCE_BADGE[track.source]}</span>
+40 -4
View File
@@ -151,9 +151,16 @@ select:focus {
.layout { .layout {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 18px; gap: 18px;
align-items: start; align-items: stretch;
}
.layout > div {
display: flex;
flex-direction: column;
gap: 18px;
min-width: 0;
} }
@media (max-width: 900px) { @media (max-width: 900px) {
@@ -169,8 +176,22 @@ select:focus {
padding: 18px; padding: 18px;
} }
.card + .card { /* The search column fills the available height so results have room to breathe. */
margin-top: 18px; .card.grow {
display: flex;
flex-direction: column;
min-height: 460px;
}
.card.grow .track-list {
flex: 1;
max-height: none;
}
.card.grow .empty {
flex: 1;
display: grid;
place-items: center;
} }
.card h2 { .card h2 {
@@ -473,3 +494,18 @@ select:focus {
.voice-row select { .voice-row select {
flex: 1; flex: 1;
} }
.voice-status {
flex: 1;
min-width: 0;
color: var(--muted);
font-size: 13.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.voice-status strong {
color: var(--text);
font-weight: 600;
}
+1 -1
View File
@@ -43,7 +43,7 @@ export interface ServerRef {
export interface Me { export interface Me {
user: { id: string; username: string }; user: { id: string; username: string };
servers: ServerRef[]; servers: ServerRef[];
features: { localLibrary: boolean }; features: { localLibrary: boolean; requireListener: boolean };
} }
export interface ServerStateResponse { export interface ServerStateResponse {