From 3e53f3437423abfddc600465113cc4cb8a05468b Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Tue, 8 Sep 2026 23:59:17 +0300 Subject: [PATCH] Tie playback to the listener and keep the panel's view live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 10 +- README.md | 7 +- src/api/server.ts | 745 +++++++++++----------- src/bot/format.ts | 112 ++-- src/config.ts | 8 +- src/core/manager.ts | 20 +- src/core/player.ts | 46 +- src/index.ts | 6 + src/sources/ytdlp.ts | 32 +- web/src/App.tsx | 452 ++++++------- web/src/api.ts | 174 ++--- web/src/components/NowPlaying.tsx | 377 +++++------ web/src/components/QueueList.tsx | 152 +++-- web/src/components/SearchPanel.tsx | 241 +++---- web/src/styles.css | 986 +++++++++++++++-------------- web/src/types.ts | 108 ++-- 16 files changed, 1819 insertions(+), 1657 deletions(-) diff --git a/.env.example b/.env.example index 8f86453..3e7fe40 100644 --- a/.env.example +++ b/.env.example @@ -51,8 +51,10 @@ DEFAULT_VOLUME=60 MAX_QUEUE_SIZE=500 SEARCH_RESULT_LIMIT=10 -# Через сколько секунд простоя бот выходит из голосового канала (0 — никогда). -IDLE_TIMEOUT_SECONDS=300 +# Через сколько секунд после ухода ПОСЛЕДНЕГО человека бот покидает голосовой +# канал (0 — не выходить никогда). Пока в канале кто-то есть, бот остаётся, +# даже если очередь давно закончилась. +EMPTY_TIMEOUT_SECONDS=120 # --------------------------------------------------------------- Доступ ---- # false — управлять может любой участник сервера. @@ -60,6 +62,10 @@ IDLE_TIMEOUT_SECONDS=300 REQUIRE_DJ_ROLE=false DJ_ROLE_NAME=DJ +# true — позвать бота можно только в тот голосовой канал, где вы сами находитесь +# (и панель, и команды). false — разрешить выбирать канал вручную. +REQUIRE_LISTENER=true + # ----------------------------------------------------------------- Прочее --- LOG_LEVEL=info NODE_ENV=production diff --git a/README.md b/README.md index 024c6f0..08dbc05 100644 --- a/README.md +++ b/README.md @@ -181,9 +181,14 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f засорять канал. Для этого роли бота нужно право **Manage Messages** в настройках сервера (Settings → Roles → роль бота) или в правах самого канала. Без него команда всё равно отработает, а в логе будет `could not delete command message` с причиной отказа. +- `REQUIRE_LISTENER=true` (по умолчанию) — позвать бота можно только в тот голосовой канал, где + вы сами сидите: музыка идёт за слушателем, отправить бота «куда-то ещё» из панели нельзя. + Поставьте `false`, если хотите выбирать канал вручную. - `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer` и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера. -- `IDLE_TIMEOUT_SECONDS` — через сколько секунд простоя (или пустого канала) бот выходит из войса. +- `EMPTY_TIMEOUT_SECONDS` — через сколько секунд после ухода последнего человека бот покидает + голосовой канал (по умолчанию 120, `0` — не выходить никогда). Пустая очередь поводом уйти + не считается: пока в канале кто-то есть, бот ждёт следующий трек. - `LOCAL_MEDIA_DIR` — примонтируйте том с музыкой и укажите путь внутри контейнера, тогда заработают `local:` и поиск по медиатеке. - `YTDLP_COOKIES` — путь к `cookies.txt`, см. раздел ниже. diff --git a/src/api/server.ts b/src/api/server.ts index 81b8d97..0c914cc 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -1,362 +1,383 @@ -import { existsSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import cookie from "@fastify/cookie"; -import fastifyStatic from "@fastify/static"; -import websocket from "@fastify/websocket"; -import Fastify, { type FastifyReply, type FastifyRequest } from "fastify"; -import { z } from "zod"; -import { signSession, verifyToken } from "../auth/tokens.js"; -import { config } from "../config.js"; -import { logger } from "../logger.js"; -import type { MusicManager } from "../core/manager.js"; -import type { BotStoatContext } from "../bot/context.js"; -import { fetchSelf, loginWithPassword, revokeSession } from "../stoat/rest.js"; -import { UserFacingError, type Track } from "../types.js"; - -const log = logger.child({ mod: "api" }); -const COOKIE_NAME = "mbot_session"; -const SEARCH_CACHE_TTL_MS = 15 * 60_000; -const SEARCH_CACHE_LIMIT = 5000; - -/** - * The panel never sends whole track objects back to us — it sends ids that were - * produced by a server-side search. That keeps clients from pointing playback at - * arbitrary local paths or internal URLs. - */ -const searchCache = new Map(); - -function cacheTracks(tracks: Track[]): void { - const now = Date.now(); - for (const track of tracks) searchCache.set(track.id, { track, at: now }); - if (searchCache.size > SEARCH_CACHE_LIMIT) { - for (const [id, entry] of searchCache) { - if (now - entry.at > SEARCH_CACHE_TTL_MS) searchCache.delete(id); - if (searchCache.size <= SEARCH_CACHE_LIMIT) break; - } - } -} - -function takeTracks(ids: string[]): Track[] { - const tracks: Track[] = []; - for (const id of ids) { - const entry = searchCache.get(id); - if (!entry || Date.now() - entry.at > SEARCH_CACHE_TTL_MS) continue; - tracks.push(entry.track); - } - if (tracks.length === 0) throw new UserFacingError("Результаты поиска устарели, повторите поиск"); - return tracks; -} - -interface Session { - userId: string; - username: string; -} - -declare module "fastify" { - interface FastifyRequest { - session?: Session; - } -} - -export interface ApiServerOptions { - manager: MusicManager; - context: BotStoatContext; -} - -export async function startApiServer({ manager, context }: ApiServerOptions) { - const app = Fastify({ logger: false, trustProxy: true }); - - await app.register(cookie); - await app.register(websocket); - - const secureCookies = config.PUBLIC_URL.startsWith("https://"); - - function setSessionCookie(reply: FastifyReply, token: string): void { - reply.setCookie(COOKIE_NAME, token, { - httpOnly: true, - sameSite: "lax", - secure: secureCookies, - path: "/", - maxAge: config.SESSION_TTL_HOURS * 3600, - }); - } - - async function readSession(request: FastifyRequest): Promise { - const token = request.cookies[COOKIE_NAME]; - if (!token) return null; - const claims = await verifyToken(token); - if (!claims || claims.typ !== "session") return null; - return { userId: claims.sub, username: claims.username }; - } - - async function requireSession(request: FastifyRequest, reply: FastifyReply): Promise { - const session = await readSession(request); - if (!session) { - await reply.code(401).send({ error: "Не авторизован" }); - return null; - } - request.session = session; - return session; - } - - /** Membership is re-checked on every request: roles change in Stoat, not here. */ - async function requireServerAccess( - request: FastifyRequest, - reply: FastifyReply, - serverId: string, - ): Promise { - const session = await requireSession(request, reply); - if (!session) return null; - if (!(await context.isMember(serverId, session.userId))) { - await reply.code(403).send({ error: "Нет доступа к этому серверу" }); - return null; - } - return session; - } - - app.setErrorHandler((error, _request, reply) => { - if (error instanceof UserFacingError) { - void reply.code(400).send({ error: error.message }); - return; - } - if ((error as { validation?: unknown }).validation) { - void reply.code(400).send({ error: "Некорректный запрос" }); - return; - } - log.error({ err: error }, "request failed"); - void reply.code(500).send({ error: "Внутренняя ошибка" }); - }); - - // ------------------------------------------------------------------ auth --- - - const loginSchema = z.object({ - email: z.string().min(1).optional(), - password: z.string().min(1).optional(), - mfaTicket: z.string().optional(), - totpCode: z.string().optional(), - recoveryCode: z.string().optional(), - }); - - app.post("/api/auth/login", async (request, reply) => { - const body = loginSchema.parse(request.body ?? {}); - const mfa = body.mfaTicket - ? { ticket: body.mfaTicket, totpCode: body.totpCode, recoveryCode: body.recoveryCode } - : undefined; - if (!mfa && (!body.email || !body.password)) { - throw new UserFacingError("Укажите e-mail и пароль"); - } - - const result = await loginWithPassword(body.email ?? "", body.password ?? "", mfa); - if (result.kind === "mfa") { - return reply.send({ mfaRequired: true, ticket: result.ticket, methods: result.methods }); - } - - // We only needed the session to prove who the user is; drop it immediately. - const profile = await fetchSelf(result.token).catch(() => null); - await revokeSession(result.token); - - const username = profile?.display_name || profile?.username || "user"; - const token = await signSession(result.userId, username); - setSessionCookie(reply, token); - return reply.send({ user: { id: result.userId, username } }); - }); - - app.post("/api/auth/link", async (request, reply) => { - const body = z.object({ token: z.string().min(1) }).parse(request.body ?? {}); - const claims = await verifyToken(body.token); - if (!claims || claims.typ !== "link") throw new UserFacingError("Ссылка недействительна или устарела"); - const token = await signSession(claims.sub, claims.username); - setSessionCookie(reply, token); - return reply.send({ user: { id: claims.sub, username: claims.username }, serverId: claims.srv ?? null }); - }); - - app.post("/api/auth/logout", async (_request, reply) => { - reply.clearCookie(COOKIE_NAME, { path: "/" }); - return reply.send({ ok: true }); - }); - - app.get("/api/me", async (request, reply) => { - const session = await requireSession(request, reply); - if (!session) return reply; - const servers = await context.listServersForUser(session.userId); - return reply.send({ - user: { id: session.userId, username: session.username }, - servers, - features: { localLibrary: Boolean(config.LOCAL_MEDIA_DIR) }, - }); - }); - - // --------------------------------------------------------------- player --- - - const serverParams = z.object({ id: z.string().min(1) }); - - app.get("/api/servers/:id/state", async (request, reply) => { - const { id } = serverParams.parse(request.params); - const session = await requireServerAccess(request, reply, id); - if (!session) return reply; - return reply.send({ - state: manager.snapshot(id), - voiceChannels: context.listVoiceChannels(id), - yourVoiceChannel: context.findUserVoiceChannel(id, session.userId), - canControl: await context.canControl(id, session.userId), - }); - }); - - app.get("/api/servers/:id/search", async (request, reply) => { - const { id } = serverParams.parse(request.params); - const session = await requireServerAccess(request, reply, id); - if (!session) return reply; - const { q } = z.object({ q: z.string().min(1) }).parse(request.query); - const tracks = await manager.search(q, { id: session.userId, username: session.username }); - cacheTracks(tracks); - return reply.send({ tracks }); - }); - - const playSchema = z.object({ - query: z.string().min(1).optional(), - trackIds: z.array(z.string()).optional(), - mode: z.enum(["append", "next", "now"]).default("append"), - voiceChannelId: z.string().nullable().optional(), - }); - - app.post("/api/servers/:id/play", async (request, reply) => { - const { id } = serverParams.parse(request.params); - const session = await requireServerAccess(request, reply, id); - if (!session) return reply; - const body = playSchema.parse(request.body ?? {}); - const requester = { id: session.userId, username: session.username }; - - const outcome = body.trackIds?.length - ? await manager.enqueueTracks(id, requester, takeTracks(body.trackIds), { - mode: body.mode, - voiceChannelId: body.voiceChannelId ?? null, - }) - : await manager.play(id, requester, body.query ?? "", { - mode: body.mode, - voiceChannelId: body.voiceChannelId ?? null, - }); - - return reply.send({ ok: true, added: outcome.tracks.length, state: manager.snapshot(id) }); - }); - - const actions: Record Promise> = { - pause: (serverId, userId) => manager.pause(serverId, userId), - resume: (serverId, userId) => manager.resume(serverId, userId), - toggle: (serverId, userId) => manager.togglePause(serverId, userId), - skip: (serverId, userId, body) => - manager.skip(serverId, userId, z.object({ count: z.number().int().min(1).default(1) }).parse(body ?? {}).count), - stop: (serverId, userId) => manager.stop(serverId, userId), - shuffle: (serverId, userId) => manager.shuffle(serverId, userId), - clear: (serverId, userId) => manager.clearQueue(serverId, userId), - leave: (serverId, userId) => manager.leave(serverId, userId), - volume: (serverId, userId, body) => - manager.setVolume(serverId, userId, z.object({ volume: z.number().min(0).max(200) }).parse(body).volume), - loop: (serverId, userId, body) => - manager.setLoop(serverId, userId, z.object({ mode: z.enum(["off", "track", "queue"]) }).parse(body).mode), - seek: (serverId, userId, body) => - manager.seek(serverId, userId, z.object({ position: z.number().min(0) }).parse(body).position), - }; - - app.post("/api/servers/:id/actions/:action", async (request, reply) => { - const { id } = serverParams.parse(request.params); - const { action } = z.object({ action: z.string() }).parse(request.params); - const session = await requireServerAccess(request, reply, id); - if (!session) return reply; - - const handler = actions[action]; - if (!handler) return reply.code(404).send({ error: "Неизвестное действие" }); - - const result = await handler(id, session.userId, request.body); - return reply.send({ ok: true, result: result ?? null, state: manager.snapshot(id) }); - }); - - app.post("/api/servers/:id/join", async (request, reply) => { - const { id } = serverParams.parse(request.params); - const session = await requireServerAccess(request, reply, id); - if (!session) return reply; - const body = z.object({ voiceChannelId: z.string().nullable().optional() }).parse(request.body ?? {}); - await manager.assertControl(id, session.userId); - await manager.connect(id, session.userId, { voiceChannelId: body.voiceChannelId ?? null }); - return reply.send({ ok: true, state: manager.snapshot(id) }); - }); - - app.delete("/api/servers/:id/queue/:trackId", async (request, reply) => { - const { id } = serverParams.parse(request.params); - const { trackId } = z.object({ trackId: z.string() }).parse(request.params); - const session = await requireServerAccess(request, reply, id); - if (!session) return reply; - await manager.remove(id, session.userId, trackId); - return reply.send({ ok: true, state: manager.snapshot(id) }); - }); - - app.post("/api/servers/:id/queue/:trackId/move", async (request, reply) => { - const { id } = serverParams.parse(request.params); - const { trackId } = z.object({ trackId: z.string() }).parse(request.params); - const session = await requireServerAccess(request, reply, id); - if (!session) return reply; - const { index } = z.object({ index: z.number().int().min(0) }).parse(request.body); - await manager.move(id, session.userId, trackId, index); - return reply.send({ ok: true, state: manager.snapshot(id) }); - }); - - // ----------------------------------------------------------- websockets --- - - const subscribers = new Map>(); - - function broadcast(serverId: string, payload: unknown): void { - const listeners = subscribers.get(serverId); - if (!listeners?.size) return; - const message = JSON.stringify(payload); - for (const socket of listeners) { - try { - socket.send(message); - } catch { - listeners.delete(socket); - } - } - } - - manager.on("update", (snapshot) => broadcast(snapshot.serverId, { type: "state", state: snapshot })); - manager.on("position", (position) => broadcast(position.serverId, { type: "position", ...position })); - - app.get("/ws", { websocket: true }, (socket, request) => { - void (async () => { - const session = await readSession(request); - const serverId = (request.query as { server?: string }).server; - if (!session || !serverId || !(await context.isMember(serverId, session.userId))) { - socket.close(4001, "unauthorized"); - return; - } - - const listeners = subscribers.get(serverId) ?? new Set(); - listeners.add(socket); - subscribers.set(serverId, listeners); - - socket.send(JSON.stringify({ type: "state", state: manager.snapshot(serverId) })); - socket.on("close", () => { - listeners.delete(socket); - if (listeners.size === 0) subscribers.delete(serverId); - }); - })(); - }); - - // --------------------------------------------------------------- static --- - - const webRoot = path.resolve(fileURLToPath(new URL("../..", import.meta.url)), "web/dist"); - if (existsSync(webRoot)) { - await app.register(fastifyStatic, { root: webRoot }); - app.setNotFoundHandler((request, reply) => { - if (request.url.startsWith("/api") || request.url.startsWith("/ws")) { - return reply.code(404).send({ error: "Not found" }); - } - return reply.sendFile("index.html"); - }); - } else { - log.warn({ webRoot }, "web/dist not found — panel UI is not served (run npm run web:build)"); - } - - await app.listen({ port: config.PORT, host: config.HOST }); - log.info({ port: config.PORT, url: config.PUBLIC_URL }, "panel is listening"); - return app; -} +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import cookie from "@fastify/cookie"; +import fastifyStatic from "@fastify/static"; +import websocket from "@fastify/websocket"; +import Fastify, { type FastifyReply, type FastifyRequest } from "fastify"; +import { z } from "zod"; +import { signSession, verifyToken } from "../auth/tokens.js"; +import { config } from "../config.js"; +import { logger } from "../logger.js"; +import type { MusicManager } from "../core/manager.js"; +import type { BotStoatContext } from "../bot/context.js"; +import { fetchSelf, loginWithPassword, revokeSession } from "../stoat/rest.js"; +import { UserFacingError, type Track } from "../types.js"; + +const log = logger.child({ mod: "api" }); +const COOKIE_NAME = "mbot_session"; +const SEARCH_CACHE_TTL_MS = 15 * 60_000; +const SEARCH_CACHE_LIMIT = 5000; + +/** + * The panel never sends whole track objects back to us — it sends ids that were + * produced by a server-side search. That keeps clients from pointing playback at + * arbitrary local paths or internal URLs. + */ +const searchCache = new Map(); + +function cacheTracks(tracks: Track[]): void { + const now = Date.now(); + for (const track of tracks) searchCache.set(track.id, { track, at: now }); + if (searchCache.size > SEARCH_CACHE_LIMIT) { + for (const [id, entry] of searchCache) { + if (now - entry.at > SEARCH_CACHE_TTL_MS) searchCache.delete(id); + if (searchCache.size <= SEARCH_CACHE_LIMIT) break; + } + } +} + +function takeTracks(ids: string[]): Track[] { + const tracks: Track[] = []; + for (const id of ids) { + const entry = searchCache.get(id); + if (!entry || Date.now() - entry.at > SEARCH_CACHE_TTL_MS) continue; + tracks.push(entry.track); + } + if (tracks.length === 0) throw new UserFacingError("Результаты поиска устарели, повторите поиск"); + return tracks; +} + +interface Session { + userId: string; + username: string; +} + +declare module "fastify" { + interface FastifyRequest { + session?: Session; + } +} + +export interface ApiServerOptions { + manager: MusicManager; + context: BotStoatContext; +} + +export async function startApiServer({ manager, context }: ApiServerOptions) { + const app = Fastify({ logger: false, trustProxy: true }); + + await app.register(cookie); + await app.register(websocket); + + const secureCookies = config.PUBLIC_URL.startsWith("https://"); + + function setSessionCookie(reply: FastifyReply, token: string): void { + reply.setCookie(COOKIE_NAME, token, { + httpOnly: true, + sameSite: "lax", + secure: secureCookies, + path: "/", + maxAge: config.SESSION_TTL_HOURS * 3600, + }); + } + + async function readSession(request: FastifyRequest): Promise { + const token = request.cookies[COOKIE_NAME]; + if (!token) return null; + const claims = await verifyToken(token); + if (!claims || claims.typ !== "session") return null; + return { userId: claims.sub, username: claims.username }; + } + + async function requireSession(request: FastifyRequest, reply: FastifyReply): Promise { + const session = await readSession(request); + if (!session) { + await reply.code(401).send({ error: "Не авторизован" }); + return null; + } + request.session = session; + return session; + } + + /** Membership is re-checked on every request: roles change in Stoat, not here. */ + async function requireServerAccess( + request: FastifyRequest, + reply: FastifyReply, + serverId: string, + ): Promise { + const session = await requireSession(request, reply); + if (!session) return null; + if (!(await context.isMember(serverId, session.userId))) { + await reply.code(403).send({ error: "Нет доступа к этому серверу" }); + return null; + } + return session; + } + + app.setErrorHandler((error, _request, reply) => { + if (error instanceof UserFacingError) { + void reply.code(400).send({ error: error.message }); + return; + } + if ((error as { validation?: unknown }).validation) { + void reply.code(400).send({ error: "Некорректный запрос" }); + return; + } + log.error({ err: error }, "request failed"); + void reply.code(500).send({ error: "Внутренняя ошибка" }); + }); + + // ------------------------------------------------------------------ auth --- + + const loginSchema = z.object({ + email: z.string().min(1).optional(), + password: z.string().min(1).optional(), + mfaTicket: z.string().optional(), + totpCode: z.string().optional(), + recoveryCode: z.string().optional(), + }); + + app.post("/api/auth/login", async (request, reply) => { + const body = loginSchema.parse(request.body ?? {}); + const mfa = body.mfaTicket + ? { ticket: body.mfaTicket, totpCode: body.totpCode, recoveryCode: body.recoveryCode } + : undefined; + if (!mfa && (!body.email || !body.password)) { + throw new UserFacingError("Укажите e-mail и пароль"); + } + + const result = await loginWithPassword(body.email ?? "", body.password ?? "", mfa); + if (result.kind === "mfa") { + return reply.send({ mfaRequired: true, ticket: result.ticket, methods: result.methods }); + } + + // We only needed the session to prove who the user is; drop it immediately. + const profile = await fetchSelf(result.token).catch(() => null); + await revokeSession(result.token); + + const username = profile?.display_name || profile?.username || "user"; + const token = await signSession(result.userId, username); + setSessionCookie(reply, token); + return reply.send({ user: { id: result.userId, username } }); + }); + + app.post("/api/auth/link", async (request, reply) => { + const body = z.object({ token: z.string().min(1) }).parse(request.body ?? {}); + const claims = await verifyToken(body.token); + if (!claims || claims.typ !== "link") throw new UserFacingError("Ссылка недействительна или устарела"); + const token = await signSession(claims.sub, claims.username); + setSessionCookie(reply, token); + return reply.send({ user: { id: claims.sub, username: claims.username }, serverId: claims.srv ?? null }); + }); + + app.post("/api/auth/logout", async (_request, reply) => { + reply.clearCookie(COOKIE_NAME, { path: "/" }); + return reply.send({ ok: true }); + }); + + app.get("/api/me", async (request, reply) => { + const session = await requireSession(request, reply); + if (!session) return reply; + const servers = await context.listServersForUser(session.userId); + return reply.send({ + user: { id: session.userId, username: session.username }, + servers, + features: { + localLibrary: Boolean(config.LOCAL_MEDIA_DIR), + requireListener: config.REQUIRE_LISTENER, + }, + }); + }); + + // --------------------------------------------------------------- player --- + + const serverParams = z.object({ id: z.string().min(1) }); + + app.get("/api/servers/:id/state", async (request, reply) => { + const { id } = serverParams.parse(request.params); + const session = await requireServerAccess(request, reply, id); + if (!session) return reply; + return reply.send({ + state: manager.snapshot(id), + voiceChannels: context.listVoiceChannels(id), + yourVoiceChannel: context.findUserVoiceChannel(id, session.userId), + canControl: await context.canControl(id, session.userId), + }); + }); + + app.get("/api/servers/:id/search", async (request, reply) => { + const { id } = serverParams.parse(request.params); + const session = await requireServerAccess(request, reply, id); + if (!session) return reply; + const { q } = z.object({ q: z.string().min(1) }).parse(request.query); + const tracks = await manager.search(q, { id: session.userId, username: session.username }); + cacheTracks(tracks); + return reply.send({ tracks }); + }); + + const playSchema = z.object({ + query: z.string().min(1).optional(), + trackIds: z.array(z.string()).optional(), + mode: z.enum(["append", "next", "now"]).default("append"), + voiceChannelId: z.string().nullable().optional(), + }); + + app.post("/api/servers/:id/play", async (request, reply) => { + const { id } = serverParams.parse(request.params); + const session = await requireServerAccess(request, reply, id); + if (!session) return reply; + const body = playSchema.parse(request.body ?? {}); + const requester = { id: session.userId, username: session.username }; + + const outcome = body.trackIds?.length + ? await manager.enqueueTracks(id, requester, takeTracks(body.trackIds), { + mode: body.mode, + voiceChannelId: body.voiceChannelId ?? null, + }) + : await manager.play(id, requester, body.query ?? "", { + mode: body.mode, + voiceChannelId: body.voiceChannelId ?? null, + }); + + return reply.send({ ok: true, added: outcome.tracks.length, state: manager.snapshot(id) }); + }); + + const actions: Record Promise> = { + pause: (serverId, userId) => manager.pause(serverId, userId), + resume: (serverId, userId) => manager.resume(serverId, userId), + toggle: (serverId, userId) => manager.togglePause(serverId, userId), + skip: (serverId, userId, body) => + manager.skip(serverId, userId, z.object({ count: z.number().int().min(1).default(1) }).parse(body ?? {}).count), + stop: (serverId, userId) => manager.stop(serverId, userId), + shuffle: (serverId, userId) => manager.shuffle(serverId, userId), + clear: (serverId, userId) => manager.clearQueue(serverId, userId), + leave: (serverId, userId) => manager.leave(serverId, userId), + volume: (serverId, userId, body) => + manager.setVolume(serverId, userId, z.object({ volume: z.number().min(0).max(200) }).parse(body).volume), + loop: (serverId, userId, body) => + manager.setLoop(serverId, userId, z.object({ mode: z.enum(["off", "track", "queue"]) }).parse(body).mode), + seek: (serverId, userId, body) => + manager.seek(serverId, userId, z.object({ position: z.number().min(0) }).parse(body).position), + }; + + app.post("/api/servers/:id/actions/:action", async (request, reply) => { + const { id } = serverParams.parse(request.params); + const { action } = z.object({ action: z.string() }).parse(request.params); + const session = await requireServerAccess(request, reply, id); + if (!session) return reply; + + const handler = actions[action]; + if (!handler) return reply.code(404).send({ error: "Неизвестное действие" }); + + const result = await handler(id, session.userId, request.body); + return reply.send({ ok: true, result: result ?? null, state: manager.snapshot(id) }); + }); + + app.post("/api/servers/:id/join", async (request, reply) => { + const { id } = serverParams.parse(request.params); + const session = await requireServerAccess(request, reply, id); + if (!session) return reply; + const body = z.object({ voiceChannelId: z.string().nullable().optional() }).parse(request.body ?? {}); + await manager.assertControl(id, session.userId); + await manager.connect(id, session.userId, { voiceChannelId: body.voiceChannelId ?? null }); + return reply.send({ ok: true, state: manager.snapshot(id) }); + }); + + app.delete("/api/servers/:id/queue/:trackId", async (request, reply) => { + const { id } = serverParams.parse(request.params); + const { trackId } = z.object({ trackId: z.string() }).parse(request.params); + const session = await requireServerAccess(request, reply, id); + if (!session) return reply; + await manager.remove(id, session.userId, trackId); + return reply.send({ ok: true, state: manager.snapshot(id) }); + }); + + app.post("/api/servers/:id/queue/:trackId/move", async (request, reply) => { + const { id } = serverParams.parse(request.params); + const { trackId } = z.object({ trackId: z.string() }).parse(request.params); + const session = await requireServerAccess(request, reply, id); + if (!session) return reply; + const { index } = z.object({ index: z.number().int().min(0) }).parse(request.body); + await manager.move(id, session.userId, trackId, index); + return reply.send({ ok: true, state: manager.snapshot(id) }); + }); + + // ----------------------------------------------------------- websockets --- + + const subscribers = new Map>(); + + function broadcast(serverId: string, payload: unknown): void { + const listeners = subscribers.get(serverId); + if (!listeners?.size) return; + const message = JSON.stringify(payload); + for (const socket of listeners) { + try { + socket.send(message); + } catch { + listeners.delete(socket); + } + } + } + + manager.on("update", (snapshot) => broadcast(snapshot.serverId, { type: "state", state: snapshot })); + manager.on("position", (position) => broadcast(position.serverId, { type: "position", ...position })); + + app.get("/ws", { websocket: true }, (socket, request) => { + void (async () => { + const session = await readSession(request); + const serverId = (request.query as { server?: string }).server; + if (!session || !serverId || !(await context.isMember(serverId, session.userId))) { + socket.close(4001, "unauthorized"); + return; + } + + const listeners = subscribers.get(serverId) ?? new Set(); + listeners.add(socket); + subscribers.set(serverId, listeners); + + 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", () => { + clearInterval(presenceTimer); + listeners.delete(socket); + if (listeners.size === 0) subscribers.delete(serverId); + }); + })(); + }); + + // --------------------------------------------------------------- static --- + + const webRoot = path.resolve(fileURLToPath(new URL("../..", import.meta.url)), "web/dist"); + if (existsSync(webRoot)) { + await app.register(fastifyStatic, { root: webRoot }); + app.setNotFoundHandler((request, reply) => { + if (request.url.startsWith("/api") || request.url.startsWith("/ws")) { + return reply.code(404).send({ error: "Not found" }); + } + return reply.sendFile("index.html"); + }); + } else { + log.warn({ webRoot }, "web/dist not found — panel UI is not served (run npm run web:build)"); + } + + await app.listen({ port: config.PORT, host: config.HOST }); + log.info({ port: config.PORT, url: config.PUBLIC_URL }, "panel is listening"); + return app; +} diff --git a/src/bot/format.ts b/src/bot/format.ts index a41d95c..45a89a9 100644 --- a/src/bot/format.ts +++ b/src/bot/format.ts @@ -1,52 +1,60 @@ -import type { LoopMode, Track } from "../types.js"; - -export function formatDuration(seconds: number): string { - if (!Number.isFinite(seconds) || seconds <= 0) return "LIVE"; - const total = Math.floor(seconds); - const hours = Math.floor(total / 3600); - const minutes = Math.floor((total % 3600) / 60); - const secs = total % 60; - const pad = (value: number) => value.toString().padStart(2, "0"); - return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`; -} - -export function parseTimecode(input: string): number | null { - const trimmed = input.trim(); - if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10); - const match = /^(?:(\d+):)?(\d{1,2}):(\d{1,2})$/.exec(trimmed); - if (!match) return null; - const [, hours, minutes, seconds] = match; - return ( - Number.parseInt(hours ?? "0", 10) * 3600 + - Number.parseInt(minutes ?? "0", 10) * 60 + - Number.parseInt(seconds ?? "0", 10) - ); -} - -export function progressBar(position: number, duration: number, width = 22): string { - if (duration <= 0) return "🔴 прямой эфир"; - const ratio = Math.min(1, Math.max(0, position / duration)); - const filled = Math.round(ratio * (width - 1)); - const bar = `${"─".repeat(filled)}⬤${"─".repeat(Math.max(0, width - 1 - filled))}`; - return `\`${formatDuration(position)}\` ${bar} \`${formatDuration(duration)}\``; -} - -const SOURCE_LABEL: Record = { - youtube: "YouTube", - soundcloud: "SoundCloud", - direct: "Ссылка", - local: "Медиатека", -}; - -export function trackLine(track: Track, index?: number): string { - const prefix = index === undefined ? "" : `**${index}.** `; - const author = track.author ? ` — ${track.author}` : ""; - 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}>`; -} - -export function loopLabel(mode: LoopMode): string { - if (mode === "track") return "трек"; - if (mode === "queue") return "очередь"; - return "выключен"; -} +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 { + if (!Number.isFinite(seconds) || seconds < 0) return "0:00"; + const total = Math.floor(seconds); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + const pad = (value: number) => value.toString().padStart(2, "0"); + 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 { + const trimmed = input.trim(); + if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10); + const match = /^(?:(\d+):)?(\d{1,2}):(\d{1,2})$/.exec(trimmed); + if (!match) return null; + const [, hours, minutes, seconds] = match; + return ( + Number.parseInt(hours ?? "0", 10) * 3600 + + Number.parseInt(minutes ?? "0", 10) * 60 + + Number.parseInt(seconds ?? "0", 10) + ); +} + +export function progressBar(position: number, duration: number, width = 22): string { + if (duration <= 0) return "🔴 прямой эфир"; + const ratio = Math.min(1, Math.max(0, position / duration)); + const filled = Math.round(ratio * (width - 1)); + const bar = `${"─".repeat(filled)}⬤${"─".repeat(Math.max(0, width - 1 - filled))}`; + return `\`${formatDuration(position)}\` ${bar} \`${formatDuration(duration)}\``; +} + +const SOURCE_LABEL: Record = { + youtube: "YouTube", + soundcloud: "SoundCloud", + direct: "Ссылка", + local: "Медиатека", +}; + +export function trackLine(track: Track, index?: number): string { + const prefix = index === undefined ? "" : `**${index}.** `; + const author = track.author ? ` — ${track.author}` : ""; + const link = /^https?:/i.test(track.url) ? `[${track.title}](${track.url})` : track.title; + return `${prefix}${link}${author} \`[${formatLength(track)}]\` · ${SOURCE_LABEL[track.source]} · <@${track.requestedBy.id}>`; +} + +export function loopLabel(mode: LoopMode): string { + if (mode === "track") return "трек"; + if (mode === "queue") return "очередь"; + return "выключен"; +} diff --git a/src/config.ts b/src/config.ts index 1f42770..72ad146 100644 --- a/src/config.ts +++ b/src/config.ts @@ -50,8 +50,14 @@ const schema = z.object({ DEFAULT_VOLUME: z.coerce.number().min(0).max(200).default(60), MAX_QUEUE_SIZE: z.coerce.number().int().positive().default(500), 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"), + /** 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 .enum(["true", "false"]) .default("false") diff --git a/src/core/manager.ts b/src/core/manager.ts index 8ec32e0..e63e57c 100644 --- a/src/core/manager.ts +++ b/src/core/manager.ts @@ -141,10 +141,22 @@ export class MusicManager extends EventEmitter { const player = this.getOrCreate(serverId); if (options.textChannelId) player.textChannelId = options.textChannelId; - const target = options.voiceChannelId - ? this.chat.getVoiceChannel(options.voiceChannelId) - : (this.chat.findUserVoiceChannel(serverId, userId) ?? - (player.voiceChannelId ? this.chat.getVoiceChannel(player.voiceChannelId) : null)); + const listening = this.chat.findUserVoiceChannel(serverId, userId); + + if (config.REQUIRE_LISTENER) { + // 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) { throw new UserFacingError("Зайдите в голосовой канал или укажите его явно"); diff --git a/src/core/player.ts b/src/core/player.ts index 8091312..5166038 100644 --- a/src/core/player.ts +++ b/src/core/player.ts @@ -77,7 +77,7 @@ export class GuildPlayer extends EventEmitter { private seekOffset = 0; /** Set while we tear playback down ourselves, so the resulting `finish` is ignored. */ private expectingStop = false; - private idleTimer: NodeJS.Timeout | null = null; + private leaveTimer: NodeJS.Timeout | null = null; private ticker: NodeJS.Timeout | null = null; private readonly log; @@ -182,7 +182,7 @@ export class GuildPlayer extends EventEmitter { }); 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); // revoice's #cleanUp() dereferences this.fProc unconditionally, so a second @@ -211,11 +211,12 @@ export class GuildPlayer extends EventEmitter { await connection.play(media); this.setStatus("idle"); + this.checkEmptyChannel(); this.log.info({ channelId }, "voice connection established"); } async leaveVoice(): Promise { - this.clearIdleTimer(); + this.cancelLeaveTimer(); this.stopTicker(); this.teardownPlayback(); this.current = null; @@ -242,8 +243,11 @@ export class GuildPlayer extends EventEmitter { private checkEmptyChannel(): void { if (!this.connection) return; - if (this.connection.getUsers().length > 0) return; - this.startIdleTimer("В канале никого не осталось"); + if (this.connection.getUsers().length > 0) { + this.cancelLeaveTimer(); + return; + } + this.startLeaveTimer(); } // -------------------------------------------------------------- playback --- @@ -272,7 +276,7 @@ export class GuildPlayer extends EventEmitter { private async startPlayback(track: Track, seekSeconds = 0): Promise { const media = this.assertReady(); - this.clearIdleTimer(); + this.cancelLeaveTimer(); this.teardownPlayback(); this.current = track; @@ -361,7 +365,6 @@ export class GuildPlayer extends EventEmitter { this.setStatus("idle"); this.publish(); if (finished) this.notify("⏹️ Очередь закончилась."); - this.startIdleTimer(); return; } @@ -385,7 +388,6 @@ export class GuildPlayer extends EventEmitter { this.stopTicker(); this.setStatus("idle"); this.publish(); - this.startIdleTimer(); } pause(): void { @@ -501,21 +503,25 @@ export class GuildPlayer extends EventEmitter { this.ticker = null; } - private clearIdleTimer(): void { - if (!this.idleTimer) return; - clearTimeout(this.idleTimer); - this.idleTimer = null; + private cancelLeaveTimer(): void { + if (!this.leaveTimer) return; + clearTimeout(this.leaveTimer); + this.leaveTimer = null; } - private startIdleTimer(reason?: string): void { - this.clearIdleTimer(); - if (config.IDLE_TIMEOUT_SECONDS <= 0 || !this.connection) return; - this.idleTimer = setTimeout(() => { - if (this.current) return; - this.notify(`👋 ${reason ?? "Нет активности"}, выхожу из голосового канала.`); + /** + * Leaving is tied to the channel being empty, never to an idle queue: the bot + * stays put with people around, waiting for the next request. + */ + private startLeaveTimer(): void { + 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(); - }, config.IDLE_TIMEOUT_SECONDS * 1000); - this.idleTimer.unref?.(); + }, config.EMPTY_TIMEOUT_SECONDS * 1000); + this.leaveTimer.unref?.(); } async destroy(): Promise { diff --git a/src/index.ts b/src/index.ts index 486b349..b391425 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,12 @@ async function main(): Promise { ); } + 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(); if (cookies === "ok") { logger.info({ path: config.YTDLP_COOKIES }, "using YouTube cookies"); diff --git a/src/sources/ytdlp.ts b/src/sources/ytdlp.ts index 432a7dc..4e0c4ff 100644 --- a/src/sources/ytdlp.ts +++ b/src/sources/ytdlp.ts @@ -61,8 +61,14 @@ export async function checkCookies(): Promise { } } -function runYtDlp(args: string[], timeoutMs = 45_000): Promise { - return new Promise((resolve, reject) => { +interface YtDlpRun { + stdout: string; + stderr: string; + code: number | null; +} + +function runYtDlp(args: string[], timeoutMs = 45_000): Promise { + return new Promise((resolve, reject) => { const child = spawn(config.YTDLP_PATH, args, { windowsHide: true }); let stdout = ""; let stderr = ""; @@ -88,7 +94,7 @@ function runYtDlp(args: string[], timeoutMs = 45_000): Promise { child.on("close", (code) => { clearTimeout(timer); if (code === 0 || stdout.trim().length > 0) { - resolve(stdout); + resolve({ stdout, stderr, code }); return; } log.warn({ code, stderr: stderr.slice(0, 800) }, "yt-dlp failed"); @@ -160,18 +166,26 @@ export async function search( requestedBy: Requester, ): Promise { const prefix = kind === "soundcloud" ? "scsearch" : "ytsearch"; - const stdout = await runYtDlp([ + const { stdout, stderr } = await runYtDlp([ ...baseArgs(), "--flat-playlist", "--dump-json", `${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. */ export async function resolveUrl(url: string, requestedBy: Requester, maxTracks: number): Promise { - const stdout = await runYtDlp([ + const { stdout } = await runYtDlp([ ...baseArgs(), "--flat-playlist", "--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). */ export async function resolveStreamUrl(pageUrl: string): Promise { - 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); if (!url) throw new UserFacingError("Не удалось получить ссылку на аудиопоток"); return url; @@ -252,8 +266,8 @@ export function openAudioStream(pageUrl: string): AudioProcess { export async function checkAvailable(): Promise { try { - const out = await runYtDlp(["--version"], 15_000); - return out.trim() || null; + const { stdout } = await runYtDlp(["--version"], 15_000); + return stdout.trim() || null; } catch { return null; } diff --git a/web/src/App.tsx b/web/src/App.tsx index 5d05a92..82b253e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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(null); - const [loading, setLoading] = useState(true); - const [serverId, setServerId] = useState(null); - const [state, setState] = useState(null); - const [position, setPosition] = useState(0); - const [voiceChannels, setVoiceChannels] = useState([]); - const [yourVoiceChannel, setYourVoiceChannel] = useState(null); - const [canControl, setCanControl] = useState(false); - const [error, setError] = useState(null); - const socketRef = useRef(null); - - /** Consumes the one-time link issued by the `!panel` chat command. */ - const consumeLinkToken = useCallback(async (): Promise => { - 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 = {}) => { - if (!serverId) return; - setError(null); - try { - await api.action(serverId, action, payload); - } catch (err) { - setError(err instanceof Error ? err.message : "Действие не выполнено"); - } - }, - [serverId], - ); - - if (loading) return
Загрузка…
; - if (!me) return void loadMe()} />; - - return ( -
-
-
- - Stoat Music -
- - {me.servers.length > 0 && ( - - )} - -
- {me.user.username} - -
- - {error &&
{error}
} - - {!serverId || !state ? ( -
- Бот не состоит ни в одном общем с вами сервере. Пригласите его и обновите страницу. -
- ) : ( -
-
- - { - 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")} - /> -
- -
- 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.map((track, index) => ( -
  • - {index + 1} -
    -
    {track.title}
    -
    {track.author ?? ""}
    -
    -
  • - ))} -
-
- )} -
-
- )} -
- ); -} +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(null); + const [loading, setLoading] = useState(true); + const [serverId, setServerId] = useState(null); + const [state, setState] = useState(null); + const [position, setPosition] = useState(0); + const [voiceChannels, setVoiceChannels] = useState([]); + const [yourVoiceChannel, setYourVoiceChannel] = useState(null); + const [canControl, setCanControl] = useState(false); + const [error, setError] = useState(null); + const socketRef = useRef(null); + + /** Consumes the one-time link issued by the `!panel` chat command. */ + const consumeLinkToken = useCallback(async (): Promise => { + 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 = {}) => { + if (!serverId) return; + setError(null); + try { + await api.action(serverId, action, payload); + } catch (err) { + setError(err instanceof Error ? err.message : "Действие не выполнено"); + } + }, + [serverId], + ); + + if (loading) return
Загрузка…
; + if (!me) return void loadMe()} />; + + return ( +
+
+
+ + Stoat Music +
+ + {me.servers.length > 0 && ( + + )} + +
+ {me.user.username} + +
+ + {error &&
{error}
} + + {!serverId || !state ? ( +
+ Бот не состоит ни в одном общем с вами сервере. Пригласите его и обновите страницу. +
+ ) : ( +
+
+ +
+ +
+ 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)); + }} + /> + + { + 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 && ( +
+

История

+
    + {state.history.map((track, index) => ( +
  • + {index + 1} +
    +
    {track.title}
    +
    {track.author ?? ""}
    +
    +
  • + ))} +
+
+ )} +
+
+ )} +
+ ); +} diff --git a/web/src/api.ts b/web/src/api.ts index 05b15de..c79823e 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,83 +1,91 @@ -import type { Me, ServerStateResponse, Track } from "./types"; - -export class ApiError extends Error {} - -async function request(path: string, init: RequestInit = {}): Promise { - const res = await fetch(path, { - credentials: "same-origin", - headers: init.body ? { "content-type": "application/json" } : undefined, - ...init, - }); - const text = await res.text(); - const body = text ? JSON.parse(text) : null; - if (!res.ok) throw new ApiError(body?.error ?? `Ошибка ${res.status}`); - return body as T; -} - -export interface LoginResponse { - user?: { id: string; username: string }; - mfaRequired?: boolean; - ticket?: string; - methods?: string[]; -} - -export const api = { - me: () => request("/api/me"), - - login: (payload: { - email?: string; - password?: string; - mfaTicket?: string; - totpCode?: string; - recoveryCode?: string; - }) => request("/api/auth/login", { method: "POST", body: JSON.stringify(payload) }), - - loginWithLink: (token: string) => - request<{ user: { id: string; username: string }; serverId: string | null }>("/api/auth/link", { - method: "POST", - body: JSON.stringify({ token }), - }), - - logout: () => request<{ ok: true }>("/api/auth/logout", { method: "POST" }), - - state: (serverId: string) => request(`/api/servers/${serverId}/state`), - - search: (serverId: string, query: string) => - request<{ tracks: Track[] }>(`/api/servers/${serverId}/search?q=${encodeURIComponent(query)}`), - - play: ( - serverId: string, - payload: { query?: string; trackIds?: string[]; mode?: "append" | "next" | "now"; voiceChannelId?: string | null }, - ) => request<{ ok: true }>(`/api/servers/${serverId}/play`, { method: "POST", body: JSON.stringify(payload) }), - - action: (serverId: string, action: string, payload: Record = {}) => - request<{ ok: true }>(`/api/servers/${serverId}/actions/${action}`, { - method: "POST", - body: JSON.stringify(payload), - }), - - join: (serverId: string, voiceChannelId: string | null) => - request<{ ok: true }>(`/api/servers/${serverId}/join`, { - method: "POST", - body: JSON.stringify({ voiceChannelId }), - }), - - removeTrack: (serverId: string, trackId: string) => - request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}`, { method: "DELETE" }), - - moveTrack: (serverId: string, trackId: string, index: number) => - request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}/move`, { - method: "POST", - body: JSON.stringify({ index }), - }), -}; - -export function formatDuration(seconds: number): string { - if (!Number.isFinite(seconds) || seconds <= 0) return "LIVE"; - const total = Math.floor(seconds); - const hours = Math.floor(total / 3600); - const minutes = Math.floor((total % 3600) / 60); - const secs = total % 60; - const pad = (value: number) => value.toString().padStart(2, "0"); - return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`; -} +import type { Me, ServerStateResponse, Track } from "./types"; + +export class ApiError extends Error {} + +async function request(path: string, init: RequestInit = {}): Promise { + const res = await fetch(path, { + credentials: "same-origin", + headers: init.body ? { "content-type": "application/json" } : undefined, + ...init, + }); + const text = await res.text(); + const body = text ? JSON.parse(text) : null; + if (!res.ok) throw new ApiError(body?.error ?? `Ошибка ${res.status}`); + return body as T; +} + +export interface LoginResponse { + user?: { id: string; username: string }; + mfaRequired?: boolean; + ticket?: string; + methods?: string[]; +} + +export const api = { + me: () => request("/api/me"), + + login: (payload: { + email?: string; + password?: string; + mfaTicket?: string; + totpCode?: string; + recoveryCode?: string; + }) => request("/api/auth/login", { method: "POST", body: JSON.stringify(payload) }), + + loginWithLink: (token: string) => + request<{ user: { id: string; username: string }; serverId: string | null }>("/api/auth/link", { + method: "POST", + body: JSON.stringify({ token }), + }), + + logout: () => request<{ ok: true }>("/api/auth/logout", { method: "POST" }), + + state: (serverId: string) => request(`/api/servers/${serverId}/state`), + + search: (serverId: string, query: string) => + request<{ tracks: Track[] }>(`/api/servers/${serverId}/search?q=${encodeURIComponent(query)}`), + + play: ( + serverId: string, + payload: { query?: string; trackIds?: string[]; mode?: "append" | "next" | "now"; voiceChannelId?: string | null }, + ) => request<{ ok: true }>(`/api/servers/${serverId}/play`, { method: "POST", body: JSON.stringify(payload) }), + + action: (serverId: string, action: string, payload: Record = {}) => + request<{ ok: true }>(`/api/servers/${serverId}/actions/${action}`, { + method: "POST", + body: JSON.stringify(payload), + }), + + join: (serverId: string, voiceChannelId: string | null) => + request<{ ok: true }>(`/api/servers/${serverId}/join`, { + method: "POST", + body: JSON.stringify({ voiceChannelId }), + }), + + removeTrack: (serverId: string, trackId: string) => + request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}`, { method: "DELETE" }), + + moveTrack: (serverId: string, trackId: string, index: number) => + request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}/move`, { + method: "POST", + body: JSON.stringify({ index }), + }), +}; + +/** Clock formatting for any position or length; 0 is a legitimate "0:00". */ +export function formatDuration(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return "0:00"; + const total = Math.floor(seconds); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + const pad = (value: number) => value.toString().padStart(2, "0"); + 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); +} diff --git a/web/src/components/NowPlaying.tsx b/web/src/components/NowPlaying.tsx index 24f0d70..f81290e 100644 --- a/web/src/components/NowPlaying.tsx +++ b/web/src/components/NowPlaying.tsx @@ -1,177 +1,200 @@ -import { useEffect, useState, type MouseEvent } from "react"; -import { formatDuration } from "../api"; -import type { LoopMode, PlayerState, VoiceChannel } from "../types"; - -interface Props { - state: PlayerState; - position: number; - canControl: boolean; - voiceChannels: VoiceChannel[]; - yourVoiceChannel: VoiceChannel | null; - onAction(action: string, payload?: Record): void; - onSeek(seconds: number): void; - onJoin(channelId: string | null): void; -} - -const STATUS_LABEL: Record = { - idle: "ожидание", - connecting: "подключение", - buffering: "буферизация", - playing: "играет", - paused: "пауза", -}; - -const LOOP_LABEL: Record = { - off: "🔁 выкл", - track: "🔂 трек", - queue: "🔁 очередь", -}; - -export function NowPlaying({ - state, - position, - canControl, - voiceChannels, - yourVoiceChannel, - onAction, - onSeek, - onJoin, -}: Props) { - const [volume, setVolume] = useState(state.volume); - const [channelId, setChannelId] = useState( - state.voiceChannelId ?? yourVoiceChannel?.id ?? voiceChannels[0]?.id ?? "", - ); - - useEffect(() => setVolume(state.volume), [state.volume]); - useEffect(() => { - if (state.voiceChannelId) setChannelId(state.voiceChannelId); - }, [state.voiceChannelId]); - - const track = state.current; - const duration = track?.duration ?? 0; - const ratio = duration > 0 ? Math.min(1, position / duration) : 0; - const isPlaying = state.status === "playing" || state.status === "buffering"; - - function seekFromClick(event: MouseEvent) { - if (!track || duration <= 0 || !canControl) return; - const rect = event.currentTarget.getBoundingClientRect(); - const fraction = (event.clientX - rect.left) / rect.width; - onSeek(Math.max(0, Math.min(duration - 1, fraction * duration))); - } - - const nextLoop: LoopMode = state.loop === "off" ? "track" : state.loop === "track" ? "queue" : "off"; - - return ( -
-

- Сейчас играет{" "} - - {STATUS_LABEL[state.status]} - -

- -
- {track?.thumbnail ? ( - - ) : ( -
🎵
- )} -
-
- {track ? ( - /^https?:/.test(track.url) ? ( - - {track.title} - - ) : ( - track.title - ) - ) : ( - "Тишина" - )} -
-
- {track - ? [track.author, `запросил ${track.requestedBy.username}`].filter(Boolean).join(" · ") - : "Очередь пуста — найдите что-нибудь слева"} -
-
-
- -
-
- {track ? formatDuration(position) : "0:00"} - {track?.isLive ? "LIVE" : formatDuration(duration)} -
-
-
-
- -
- - - - - - -
- 🔊 - setVolume(Number(event.target.value))} - onMouseUp={() => onAction("volume", { volume })} - onTouchEnd={() => onAction("volume", { volume })} - /> - {volume}% -
-
- -
- - - -
-
- ); -} +import { useEffect, useState, type MouseEvent } from "react"; +import { formatDuration, formatLength } from "../api"; +import type { LoopMode, PlayerState, VoiceChannel } from "../types"; + +interface Props { + state: PlayerState; + position: number; + canControl: boolean; + voiceChannels: VoiceChannel[]; + yourVoiceChannel: VoiceChannel | null; + requireListener: boolean; + onAction(action: string, payload?: Record): void; + onSeek(seconds: number): void; + onJoin(channelId: string | null): void; +} + +const STATUS_LABEL: Record = { + idle: "ожидание", + connecting: "подключение", + buffering: "буферизация", + playing: "играет", + paused: "пауза", +}; + +const LOOP_LABEL: Record = { + off: "🔁 выкл", + track: "🔂 трек", + queue: "🔁 очередь", +}; + +export function NowPlaying({ + state, + position, + canControl, + voiceChannels, + yourVoiceChannel, + requireListener, + onAction, + onSeek, + onJoin, +}: Props) { + const [volume, setVolume] = useState(state.volume); + const [channelId, setChannelId] = useState( + state.voiceChannelId ?? yourVoiceChannel?.id ?? voiceChannels[0]?.id ?? "", + ); + + useEffect(() => setVolume(state.volume), [state.volume]); + useEffect(() => { + if (state.voiceChannelId) setChannelId(state.voiceChannelId); + }, [state.voiceChannelId]); + + const track = state.current; + const duration = track?.duration ?? 0; + const ratio = duration > 0 ? Math.min(1, position / duration) : 0; + const isPlaying = state.status === "playing" || state.status === "buffering"; + + function seekFromClick(event: MouseEvent) { + if (!track || duration <= 0 || !canControl) return; + const rect = event.currentTarget.getBoundingClientRect(); + const fraction = (event.clientX - rect.left) / rect.width; + onSeek(Math.max(0, Math.min(duration - 1, fraction * duration))); + } + + const nextLoop: LoopMode = state.loop === "off" ? "track" : state.loop === "track" ? "queue" : "off"; + + return ( +
+

+ Сейчас играет{" "} + + {STATUS_LABEL[state.status]} + +

+ +
+ {track?.thumbnail ? ( + + ) : ( +
🎵
+ )} +
+
+ {track ? ( + /^https?:/.test(track.url) ? ( + + {track.title} + + ) : ( + track.title + ) + ) : ( + "Тишина" + )} +
+
+ {track + ? [track.author, `запросил ${track.requestedBy.username}`].filter(Boolean).join(" · ") + : "Очередь пуста — найдите что-нибудь слева"} +
+
+
+ +
+
+ {formatDuration(track ? position : 0)} + {track ? formatLength(track) : "0:00"} +
+
+
+
+ +
+ + + + + + +
+ 🔊 + setVolume(Number(event.target.value))} + onMouseUp={() => onAction("volume", { volume })} + onTouchEnd={() => onAction("volume", { volume })} + /> + {volume}% +
+
+ +
+ {requireListener ? ( + // Playback follows the listener, so there is nothing to choose here: + // the bot joins the channel you are sitting in. + + {yourVoiceChannel ? ( + <> + Вы в канале {yourVoiceChannel.name} + + ) : ( + "Вы не в голосовом канале" + )} + {state.voiceChannelName ? ` · бот в «${state.voiceChannelName}»` : " · бот не в канале"} + + ) : ( + + )} + + +
+
+ ); +} diff --git a/web/src/components/QueueList.tsx b/web/src/components/QueueList.tsx index 80bfed0..464fdcf 100644 --- a/web/src/components/QueueList.tsx +++ b/web/src/components/QueueList.tsx @@ -1,77 +1,75 @@ -import { formatDuration } from "../api"; -import type { Track } from "../types"; - -interface Props { - tracks: Track[]; - canControl: boolean; - onRemove(track: Track): void; - onMove(track: Track, index: number): void; - onClear(): void; -} - -export function QueueList({ tracks, canControl, onRemove, onMove, onClear }: Props) { - const total = tracks.reduce((acc, track) => acc + track.duration, 0); - - return ( -
-

- Очередь · {tracks.length} - {total > 0 ? ` · ${formatDuration(total)}` : ""} -

- - {tracks.length === 0 ? ( -
Очередь пуста
- ) : ( - <> -
    - {tracks.map((track, index) => ( -
  • - {index + 1} - {track.thumbnail ? :
    } -
    -
    {track.title}
    -
    - {[track.author, track.isLive ? "LIVE" : formatDuration(track.duration), track.requestedBy.username] - .filter(Boolean) - .join(" · ")} -
    -
    -
    - - - - -
    -
  • - ))} -
-
- -
- - )} -
- ); -} +import { formatDuration, formatLength } from "../api"; +import type { Track } from "../types"; + +interface Props { + tracks: Track[]; + canControl: boolean; + onRemove(track: Track): void; + onMove(track: Track, index: number): void; + onClear(): void; +} + +export function QueueList({ tracks, canControl, onRemove, onMove, onClear }: Props) { + const total = tracks.reduce((acc, track) => acc + track.duration, 0); + + return ( +
+

+ Очередь · {tracks.length} + {total > 0 ? ` · ${formatDuration(total)}` : ""} +

+ + {tracks.length === 0 ? ( +
Очередь пуста
+ ) : ( + <> +
    + {tracks.map((track, index) => ( +
  • + {index + 1} + {track.thumbnail ? :
    } +
    +
    {track.title}
    +
    + {[track.author, formatLength(track), track.requestedBy.username].filter(Boolean).join(" · ")} +
    +
    +
    + + + + +
    +
  • + ))} +
+
+ +
+ + )} +
+ ); +} diff --git a/web/src/components/SearchPanel.tsx b/web/src/components/SearchPanel.tsx index 35cdaa8..3f8921d 100644 --- a/web/src/components/SearchPanel.tsx +++ b/web/src/components/SearchPanel.tsx @@ -1,117 +1,124 @@ -import { useState, type FormEvent } from "react"; -import { api, formatDuration } from "../api"; -import type { Track } from "../types"; - -const SOURCE_BADGE: Record = { - youtube: "YouTube", - soundcloud: "SoundCloud", - direct: "Ссылка", - local: "Медиатека", -}; - -interface Props { - serverId: string; - canControl: boolean; - localLibrary: boolean; - onError(message: string | null): void; -} - -export function SearchPanel({ serverId, canControl, localLibrary, onError }: Props) { - const [query, setQuery] = useState(""); - const [results, setResults] = useState([]); - const [busy, setBusy] = useState(false); - const [lastAdded, setLastAdded] = useState(null); - - async function submit(event: FormEvent) { - event.preventDefault(); - const value = query.trim(); - if (!value) return; - setBusy(true); - onError(null); - try { - if (/^https?:\/\//i.test(value)) { - await api.play(serverId, { query: value }); - setLastAdded(value); - setResults([]); - setQuery(""); - return; - } - const { tracks } = await api.search(serverId, value); - setResults(tracks); - } catch (err) { - onError(err instanceof Error ? err.message : "Поиск не удался"); - } finally { - setBusy(false); - } - } - - async function enqueue(track: Track, mode: "append" | "next" | "now") { - onError(null); - try { - await api.play(serverId, { trackIds: [track.id], mode }); - setLastAdded(track.title); - } catch (err) { - onError(err instanceof Error ? err.message : "Не удалось добавить трек"); - } - } - - return ( -
-

Поиск

-
- setQuery(event.target.value)} - placeholder="Название трека или ссылка…" - disabled={!canControl} - /> - -
- - {results.length === 0 ? ( -
- {lastAdded ? `Добавлено: ${lastAdded}` : "Введите запрос или вставьте ссылку — плейлисты тоже поддерживаются."} -
- ) : ( -
    - {results.map((track, index) => ( -
  • - {index + 1} - {track.thumbnail ? :
    } -
    -
    {track.title}
    -
    - {[track.author, track.isLive ? "LIVE" : formatDuration(track.duration)].filter(Boolean).join(" · ")} -
    -
    - {SOURCE_BADGE[track.source]} -
    - - - -
    -
  • - ))} -
- )} - -

- Префиксы: sc: — искать в SoundCloud, yt: — в YouTube - {localLibrary ? ( - <> - , local: — в локальной медиатеке - - ) : null} - . -

-
- ); -} +import { useState, type FormEvent } from "react"; +import { api, formatLength } from "../api"; +import type { Track } from "../types"; + +const SOURCE_BADGE: Record = { + youtube: "YouTube", + soundcloud: "SoundCloud", + direct: "Ссылка", + local: "Медиатека", +}; + +interface Props { + serverId: string; + canControl: boolean; + localLibrary: boolean; + onError(message: string | null): void; +} + +export function SearchPanel({ serverId, canControl, localLibrary, onError }: Props) { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [busy, setBusy] = useState(false); + const [lastAdded, setLastAdded] = useState(null); + const [searched, setSearched] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + const value = query.trim(); + if (!value) return; + setBusy(true); + onError(null); + try { + if (/^https?:\/\//i.test(value)) { + await api.play(serverId, { query: value }); + setLastAdded(value); + setResults([]); + setSearched(false); + setQuery(""); + return; + } + const { tracks } = await api.search(serverId, value); + setResults(tracks); + setSearched(true); + } catch (err) { + onError(err instanceof Error ? err.message : "Поиск не удался"); + } finally { + setBusy(false); + } + } + + async function enqueue(track: Track, mode: "append" | "next" | "now") { + onError(null); + try { + await api.play(serverId, { trackIds: [track.id], mode }); + setLastAdded(track.title); + } catch (err) { + onError(err instanceof Error ? err.message : "Не удалось добавить трек"); + } + } + + return ( +
+

Поиск

+
+ setQuery(event.target.value)} + placeholder="Название трека или ссылка…" + disabled={!canControl} + /> + +
+ + {results.length === 0 ? ( +
+ {searched + ? `По запросу «${query}» ничего не нашлось` + : lastAdded + ? `Добавлено: ${lastAdded}` + : "Введите запрос или вставьте ссылку — плейлисты тоже поддерживаются."} +
+ ) : ( +
    + {results.map((track, index) => ( +
  • + {index + 1} + {track.thumbnail ? :
    } +
    +
    {track.title}
    +
    + {[track.author, formatLength(track)].filter(Boolean).join(" · ")} +
    +
    + {SOURCE_BADGE[track.source]} +
    + + + +
    +
  • + ))} +
+ )} + +

+ Префиксы: sc: — искать в SoundCloud, yt: — в YouTube + {localLibrary ? ( + <> + , local: — в локальной медиатеке + + ) : null} + . +

+
+ ); +} diff --git a/web/src/styles.css b/web/src/styles.css index 48263d1..fdb5098 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1,475 +1,511 @@ -:root { - --bg: #0f1014; - --bg-elev: #171922; - --bg-elev-2: #1e2130; - --line: #2a2e3f; - --text: #e8eaf2; - --muted: #9aa0b5; - --accent: #7b6cf6; - --accent-soft: rgba(123, 108, 246, 0.16); - --danger: #f2555a; - --ok: #3ecf8e; - --radius: 14px; - color-scheme: dark; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - background: radial-gradient(1200px 600px at 20% -10%, #1b1e2e 0%, var(--bg) 60%); - color: var(--text); - font: 15px/1.5 "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; - min-height: 100vh; -} - -button { - font: inherit; - color: inherit; - border: 1px solid var(--line); - background: var(--bg-elev-2); - border-radius: 10px; - padding: 8px 12px; - cursor: pointer; - transition: background 0.15s ease, border-color 0.15s ease, transform 0.05s ease; -} - -button:hover:not(:disabled) { - background: #262a3c; - border-color: #3a3f56; -} - -button:active:not(:disabled) { - transform: translateY(1px); -} - -button:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -button.primary { - background: var(--accent); - border-color: transparent; - color: #fff; - font-weight: 600; -} - -button.primary:hover:not(:disabled) { - background: #8b7dff; -} - -button.ghost { - background: transparent; -} - -button.icon { - width: 42px; - height: 42px; - display: grid; - place-items: center; - padding: 0; - border-radius: 12px; - font-size: 16px; -} - -button.icon.big { - width: 54px; - height: 54px; - font-size: 20px; -} - -button.active { - border-color: var(--accent); - background: var(--accent-soft); - color: #cfc7ff; -} - -input, -select { - font: inherit; - color: inherit; - background: var(--bg-elev-2); - border: 1px solid var(--line); - border-radius: 10px; - padding: 10px 12px; - width: 100%; -} - -input:focus, -select:focus { - outline: 2px solid var(--accent-soft); - border-color: var(--accent); -} - -.app { - max-width: 1180px; - margin: 0 auto; - padding: 20px 18px 60px; -} - -.topbar { - display: flex; - align-items: center; - gap: 14px; - padding: 12px 4px 20px; - flex-wrap: wrap; -} - -.brand { - font-weight: 700; - font-size: 18px; - letter-spacing: -0.01em; - display: flex; - align-items: center; - gap: 9px; -} - -.brand .dot { - width: 10px; - height: 10px; - border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 14px var(--accent); -} - -.spacer { - flex: 1; -} - -.topbar select { - width: auto; - min-width: 190px; -} - -.who { - color: var(--muted); - font-size: 14px; -} - -.layout { - display: grid; - grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); - gap: 18px; - align-items: start; -} - -@media (max-width: 900px) { - .layout { - grid-template-columns: 1fr; - } -} - -.card { - background: var(--bg-elev); - border: 1px solid var(--line); - border-radius: var(--radius); - padding: 18px; -} - -.card + .card { - margin-top: 18px; -} - -.card h2 { - margin: 0 0 14px; - font-size: 14px; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--muted); - font-weight: 600; -} - -.row { - display: flex; - gap: 10px; - align-items: center; -} - -.now { - display: flex; - gap: 16px; - align-items: center; -} - -.cover { - width: 96px; - height: 96px; - border-radius: 12px; - object-fit: cover; - background: var(--bg-elev-2); - flex-shrink: 0; -} - -.cover.placeholder { - display: grid; - place-items: center; - font-size: 30px; - color: var(--muted); -} - -.now-meta { - min-width: 0; - flex: 1; -} - -.now-title { - font-size: 19px; - font-weight: 650; - line-height: 1.25; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.now-title a { - color: inherit; - text-decoration: none; -} - -.now-title a:hover { - text-decoration: underline; -} - -.now-sub { - color: var(--muted); - font-size: 14px; - margin-top: 3px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.progress { - margin-top: 16px; -} - -.bar { - height: 8px; - border-radius: 99px; - background: var(--bg-elev-2); - cursor: pointer; - position: relative; - overflow: hidden; -} - -.bar > span { - position: absolute; - inset: 0 auto 0 0; - background: linear-gradient(90deg, var(--accent), #a78bfa); - border-radius: 99px; -} - -.times { - display: flex; - justify-content: space-between; - color: var(--muted); - font-size: 12px; - margin-top: 6px; - font-variant-numeric: tabular-nums; -} - -.controls { - display: flex; - align-items: center; - gap: 8px; - margin-top: 16px; - flex-wrap: wrap; -} - -.volume { - display: flex; - align-items: center; - gap: 8px; - margin-left: auto; - min-width: 170px; -} - -.volume input[type="range"] { - width: 110px; - padding: 0; - accent-color: var(--accent); - background: transparent; - border: none; -} - -.status-pill { - font-size: 12px; - padding: 4px 9px; - border-radius: 99px; - background: var(--bg-elev-2); - color: var(--muted); - border: 1px solid var(--line); -} - -.status-pill.playing { - color: var(--ok); - border-color: rgba(62, 207, 142, 0.4); -} - -.track-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 6px; - max-height: 60vh; - overflow-y: auto; -} - -.track { - display: flex; - align-items: center; - gap: 11px; - padding: 8px 10px; - border-radius: 10px; - border: 1px solid transparent; -} - -.track:hover { - background: var(--bg-elev-2); - border-color: var(--line); -} - -.track .idx { - width: 22px; - text-align: right; - color: var(--muted); - font-size: 13px; - font-variant-numeric: tabular-nums; - flex-shrink: 0; -} - -.track .thumb { - width: 44px; - height: 44px; - border-radius: 8px; - object-fit: cover; - background: var(--bg-elev-2); - flex-shrink: 0; -} - -.track .info { - min-width: 0; - flex: 1; -} - -.track .title { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.track .sub { - color: var(--muted); - font-size: 12.5px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.track .actions { - display: flex; - gap: 6px; - opacity: 0; - transition: opacity 0.15s ease; -} - -.track:hover .actions { - opacity: 1; -} - -.track .actions button { - padding: 5px 9px; - font-size: 13px; -} - -.badge { - font-size: 11px; - padding: 2px 7px; - border-radius: 6px; - background: var(--bg-elev-2); - border: 1px solid var(--line); - color: var(--muted); - flex-shrink: 0; -} - -.empty { - color: var(--muted); - text-align: center; - padding: 26px 10px; - font-size: 14px; -} - -.search-form { - display: flex; - gap: 8px; - margin-bottom: 12px; -} - -.hint { - color: var(--muted); - font-size: 12.5px; - margin-top: 10px; -} - -.error { - background: rgba(242, 85, 90, 0.12); - border: 1px solid rgba(242, 85, 90, 0.4); - color: #ffb4b6; - padding: 10px 12px; - border-radius: 10px; - margin-bottom: 12px; - font-size: 14px; -} - -.login-wrap { - min-height: 100vh; - display: grid; - place-items: center; - padding: 20px; -} - -.login { - width: 100%; - max-width: 380px; -} - -.login h1 { - font-size: 22px; - margin: 0 0 4px; -} - -.login p.sub { - color: var(--muted); - margin: 0 0 20px; - font-size: 14px; -} - -.login label { - display: block; - font-size: 13px; - color: var(--muted); - margin: 12px 0 6px; -} - -.login button { - width: 100%; - margin-top: 18px; -} - -.voice-row { - display: flex; - gap: 8px; - align-items: center; - margin-top: 14px; - padding-top: 14px; - border-top: 1px solid var(--line); -} - -.voice-row select { - flex: 1; -} +:root { + --bg: #0f1014; + --bg-elev: #171922; + --bg-elev-2: #1e2130; + --line: #2a2e3f; + --text: #e8eaf2; + --muted: #9aa0b5; + --accent: #7b6cf6; + --accent-soft: rgba(123, 108, 246, 0.16); + --danger: #f2555a; + --ok: #3ecf8e; + --radius: 14px; + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: radial-gradient(1200px 600px at 20% -10%, #1b1e2e 0%, var(--bg) 60%); + color: var(--text); + font: 15px/1.5 "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; + min-height: 100vh; +} + +button { + font: inherit; + color: inherit; + border: 1px solid var(--line); + background: var(--bg-elev-2); + border-radius: 10px; + padding: 8px 12px; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, transform 0.05s ease; +} + +button:hover:not(:disabled) { + background: #262a3c; + border-color: #3a3f56; +} + +button:active:not(:disabled) { + transform: translateY(1px); +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +button.primary { + background: var(--accent); + border-color: transparent; + color: #fff; + font-weight: 600; +} + +button.primary:hover:not(:disabled) { + background: #8b7dff; +} + +button.ghost { + background: transparent; +} + +button.icon { + width: 42px; + height: 42px; + display: grid; + place-items: center; + padding: 0; + border-radius: 12px; + font-size: 16px; +} + +button.icon.big { + width: 54px; + height: 54px; + font-size: 20px; +} + +button.active { + border-color: var(--accent); + background: var(--accent-soft); + color: #cfc7ff; +} + +input, +select { + font: inherit; + color: inherit; + background: var(--bg-elev-2); + border: 1px solid var(--line); + border-radius: 10px; + padding: 10px 12px; + width: 100%; +} + +input:focus, +select:focus { + outline: 2px solid var(--accent-soft); + border-color: var(--accent); +} + +.app { + max-width: 1180px; + margin: 0 auto; + padding: 20px 18px 60px; +} + +.topbar { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 4px 20px; + flex-wrap: wrap; +} + +.brand { + font-weight: 700; + font-size: 18px; + letter-spacing: -0.01em; + display: flex; + align-items: center; + gap: 9px; +} + +.brand .dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 14px var(--accent); +} + +.spacer { + flex: 1; +} + +.topbar select { + width: auto; + min-width: 190px; +} + +.who { + color: var(--muted); + font-size: 14px; +} + +.layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 18px; + align-items: stretch; +} + +.layout > div { + display: flex; + flex-direction: column; + gap: 18px; + min-width: 0; +} + +@media (max-width: 900px) { + .layout { + grid-template-columns: 1fr; + } +} + +.card { + background: var(--bg-elev); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 18px; +} + +/* The search column fills the available height so results have room to breathe. */ +.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 { + margin: 0 0 14px; + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + font-weight: 600; +} + +.row { + display: flex; + gap: 10px; + align-items: center; +} + +.now { + display: flex; + gap: 16px; + align-items: center; +} + +.cover { + width: 96px; + height: 96px; + border-radius: 12px; + object-fit: cover; + background: var(--bg-elev-2); + flex-shrink: 0; +} + +.cover.placeholder { + display: grid; + place-items: center; + font-size: 30px; + color: var(--muted); +} + +.now-meta { + min-width: 0; + flex: 1; +} + +.now-title { + font-size: 19px; + font-weight: 650; + line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.now-title a { + color: inherit; + text-decoration: none; +} + +.now-title a:hover { + text-decoration: underline; +} + +.now-sub { + color: var(--muted); + font-size: 14px; + margin-top: 3px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.progress { + margin-top: 16px; +} + +.bar { + height: 8px; + border-radius: 99px; + background: var(--bg-elev-2); + cursor: pointer; + position: relative; + overflow: hidden; +} + +.bar > span { + position: absolute; + inset: 0 auto 0 0; + background: linear-gradient(90deg, var(--accent), #a78bfa); + border-radius: 99px; +} + +.times { + display: flex; + justify-content: space-between; + color: var(--muted); + font-size: 12px; + margin-top: 6px; + font-variant-numeric: tabular-nums; +} + +.controls { + display: flex; + align-items: center; + gap: 8px; + margin-top: 16px; + flex-wrap: wrap; +} + +.volume { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; + min-width: 170px; +} + +.volume input[type="range"] { + width: 110px; + padding: 0; + accent-color: var(--accent); + background: transparent; + border: none; +} + +.status-pill { + font-size: 12px; + padding: 4px 9px; + border-radius: 99px; + background: var(--bg-elev-2); + color: var(--muted); + border: 1px solid var(--line); +} + +.status-pill.playing { + color: var(--ok); + border-color: rgba(62, 207, 142, 0.4); +} + +.track-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; + max-height: 60vh; + overflow-y: auto; +} + +.track { + display: flex; + align-items: center; + gap: 11px; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid transparent; +} + +.track:hover { + background: var(--bg-elev-2); + border-color: var(--line); +} + +.track .idx { + width: 22px; + text-align: right; + color: var(--muted); + font-size: 13px; + font-variant-numeric: tabular-nums; + flex-shrink: 0; +} + +.track .thumb { + width: 44px; + height: 44px; + border-radius: 8px; + object-fit: cover; + background: var(--bg-elev-2); + flex-shrink: 0; +} + +.track .info { + min-width: 0; + flex: 1; +} + +.track .title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.track .sub { + color: var(--muted); + font-size: 12.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.track .actions { + display: flex; + gap: 6px; + opacity: 0; + transition: opacity 0.15s ease; +} + +.track:hover .actions { + opacity: 1; +} + +.track .actions button { + padding: 5px 9px; + font-size: 13px; +} + +.badge { + font-size: 11px; + padding: 2px 7px; + border-radius: 6px; + background: var(--bg-elev-2); + border: 1px solid var(--line); + color: var(--muted); + flex-shrink: 0; +} + +.empty { + color: var(--muted); + text-align: center; + padding: 26px 10px; + font-size: 14px; +} + +.search-form { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.hint { + color: var(--muted); + font-size: 12.5px; + margin-top: 10px; +} + +.error { + background: rgba(242, 85, 90, 0.12); + border: 1px solid rgba(242, 85, 90, 0.4); + color: #ffb4b6; + padding: 10px 12px; + border-radius: 10px; + margin-bottom: 12px; + font-size: 14px; +} + +.login-wrap { + min-height: 100vh; + display: grid; + place-items: center; + padding: 20px; +} + +.login { + width: 100%; + max-width: 380px; +} + +.login h1 { + font-size: 22px; + margin: 0 0 4px; +} + +.login p.sub { + color: var(--muted); + margin: 0 0 20px; + font-size: 14px; +} + +.login label { + display: block; + font-size: 13px; + color: var(--muted); + margin: 12px 0 6px; +} + +.login button { + width: 100%; + margin-top: 18px; +} + +.voice-row { + display: flex; + gap: 8px; + align-items: center; + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--line); +} + +.voice-row select { + 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; +} diff --git a/web/src/types.ts b/web/src/types.ts index cf40489..9ea3ac7 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -1,54 +1,54 @@ -export type SourceKind = "youtube" | "soundcloud" | "direct" | "local"; -export type LoopMode = "off" | "track" | "queue"; -export type PlayerStatus = "idle" | "connecting" | "buffering" | "playing" | "paused"; - -export interface Track { - id: string; - title: string; - author: string | null; - duration: number; - isLive: boolean; - url: string; - thumbnail: string | null; - source: SourceKind; - requestedBy: { id: string; username: string }; -} - -export interface PlayerState { - serverId: string; - serverName: string | null; - voiceChannelId: string | null; - voiceChannelName: string | null; - status: PlayerStatus; - current: Track | null; - position: number; - queue: Track[]; - history: Track[]; - volume: number; - loop: LoopMode; - updatedAt: number; -} - -export interface VoiceChannel { - id: string; - name: string; -} - -export interface ServerRef { - id: string; - name: string; - iconUrl: string | null; -} - -export interface Me { - user: { id: string; username: string }; - servers: ServerRef[]; - features: { localLibrary: boolean }; -} - -export interface ServerStateResponse { - state: PlayerState; - voiceChannels: VoiceChannel[]; - yourVoiceChannel: VoiceChannel | null; - canControl: boolean; -} +export type SourceKind = "youtube" | "soundcloud" | "direct" | "local"; +export type LoopMode = "off" | "track" | "queue"; +export type PlayerStatus = "idle" | "connecting" | "buffering" | "playing" | "paused"; + +export interface Track { + id: string; + title: string; + author: string | null; + duration: number; + isLive: boolean; + url: string; + thumbnail: string | null; + source: SourceKind; + requestedBy: { id: string; username: string }; +} + +export interface PlayerState { + serverId: string; + serverName: string | null; + voiceChannelId: string | null; + voiceChannelName: string | null; + status: PlayerStatus; + current: Track | null; + position: number; + queue: Track[]; + history: Track[]; + volume: number; + loop: LoopMode; + updatedAt: number; +} + +export interface VoiceChannel { + id: string; + name: string; +} + +export interface ServerRef { + id: string; + name: string; + iconUrl: string | null; +} + +export interface Me { + user: { id: string; username: string }; + servers: ServerRef[]; + features: { localLibrary: boolean; requireListener: boolean }; +} + +export interface ServerStateResponse { + state: PlayerState; + voiceChannels: VoiceChannel[]; + yourVoiceChannel: VoiceChannel | null; + canControl: boolean; +}