Tie playback to the listener and keep the panel's view live
Four things people hit while using the panel: - The bot could be sent into a channel the requester was not in, and playback could be started from nowhere. Playback now follows the listener (REQUIRE_LISTENER, on by default), and the voice row shows where you and the bot are instead of offering a free channel picker. - Voice presence only refreshed on reload, because the SDK updates channel participants without emitting an event. The socket now watches that view and pushes changes. - The bot left the channel whenever the queue ran dry. It now leaves only after the last person does, EMPTY_TIMEOUT_SECONDS later (120 by default), and stays put while anyone is still listening. - A search that yielded nothing said nothing: yt-dlp can exit 0 with an empty result, so that case now reports the reason (or "nothing found"), and searches are logged with their result count. The queue moved under the player so search owns the left column, and elapsed time no longer renders as "LIVE" — formatDuration treated 0 as a live stream, which also affected the chat's progress bar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f22b08b350
commit
3e53f34374
+8
-2
@@ -51,8 +51,10 @@ DEFAULT_VOLUME=60
|
|||||||
MAX_QUEUE_SIZE=500
|
MAX_QUEUE_SIZE=500
|
||||||
SEARCH_RESULT_LIMIT=10
|
SEARCH_RESULT_LIMIT=10
|
||||||
|
|
||||||
# Через сколько секунд простоя бот выходит из голосового канала (0 — никогда).
|
# Через сколько секунд после ухода ПОСЛЕДНЕГО человека бот покидает голосовой
|
||||||
IDLE_TIMEOUT_SECONDS=300
|
# канал (0 — не выходить никогда). Пока в канале кто-то есть, бот остаётся,
|
||||||
|
# даже если очередь давно закончилась.
|
||||||
|
EMPTY_TIMEOUT_SECONDS=120
|
||||||
|
|
||||||
# --------------------------------------------------------------- Доступ ----
|
# --------------------------------------------------------------- Доступ ----
|
||||||
# false — управлять может любой участник сервера.
|
# false — управлять может любой участник сервера.
|
||||||
@@ -60,6 +62,10 @@ IDLE_TIMEOUT_SECONDS=300
|
|||||||
REQUIRE_DJ_ROLE=false
|
REQUIRE_DJ_ROLE=false
|
||||||
DJ_ROLE_NAME=DJ
|
DJ_ROLE_NAME=DJ
|
||||||
|
|
||||||
|
# true — позвать бота можно только в тот голосовой канал, где вы сами находитесь
|
||||||
|
# (и панель, и команды). false — разрешить выбирать канал вручную.
|
||||||
|
REQUIRE_LISTENER=true
|
||||||
|
|
||||||
# ----------------------------------------------------------------- Прочее ---
|
# ----------------------------------------------------------------- Прочее ---
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
|||||||
@@ -181,9 +181,14 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f
|
|||||||
засорять канал. Для этого роли бота нужно право **Manage Messages** в настройках сервера
|
засорять канал. Для этого роли бота нужно право **Manage Messages** в настройках сервера
|
||||||
(Settings → Roles → роль бота) или в правах самого канала. Без него команда всё равно
|
(Settings → Roles → роль бота) или в правах самого канала. Без него команда всё равно
|
||||||
отработает, а в логе будет `could not delete command message` с причиной отказа.
|
отработает, а в логе будет `could not delete command message` с причиной отказа.
|
||||||
|
- `REQUIRE_LISTENER=true` (по умолчанию) — позвать бота можно только в тот голосовой канал, где
|
||||||
|
вы сами сидите: музыка идёт за слушателем, отправить бота «куда-то ещё» из панели нельзя.
|
||||||
|
Поставьте `false`, если хотите выбирать канал вручную.
|
||||||
- `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer`
|
- `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer`
|
||||||
и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера.
|
и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера.
|
||||||
- `IDLE_TIMEOUT_SECONDS` — через сколько секунд простоя (или пустого канала) бот выходит из войса.
|
- `EMPTY_TIMEOUT_SECONDS` — через сколько секунд после ухода последнего человека бот покидает
|
||||||
|
голосовой канал (по умолчанию 120, `0` — не выходить никогда). Пустая очередь поводом уйти
|
||||||
|
не считается: пока в канале кто-то есть, бот ждёт следующий трек.
|
||||||
- `LOCAL_MEDIA_DIR` — примонтируйте том с музыкой и укажите путь внутри контейнера,
|
- `LOCAL_MEDIA_DIR` — примонтируйте том с музыкой и укажите путь внутри контейнера,
|
||||||
тогда заработают `local:` и поиск по медиатеке.
|
тогда заработают `local:` и поиск по медиатеке.
|
||||||
- `YTDLP_COOKIES` — путь к `cookies.txt`, см. раздел ниже.
|
- `YTDLP_COOKIES` — путь к `cookies.txt`, см. раздел ниже.
|
||||||
|
|||||||
+383
-362
@@ -1,362 +1,383 @@
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import fastifyStatic from "@fastify/static";
|
import fastifyStatic from "@fastify/static";
|
||||||
import websocket from "@fastify/websocket";
|
import websocket from "@fastify/websocket";
|
||||||
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
|
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { signSession, verifyToken } from "../auth/tokens.js";
|
import { signSession, verifyToken } from "../auth/tokens.js";
|
||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import type { MusicManager } from "../core/manager.js";
|
import type { MusicManager } from "../core/manager.js";
|
||||||
import type { BotStoatContext } from "../bot/context.js";
|
import type { BotStoatContext } from "../bot/context.js";
|
||||||
import { fetchSelf, loginWithPassword, revokeSession } from "../stoat/rest.js";
|
import { fetchSelf, loginWithPassword, revokeSession } from "../stoat/rest.js";
|
||||||
import { UserFacingError, type Track } from "../types.js";
|
import { UserFacingError, type Track } from "../types.js";
|
||||||
|
|
||||||
const log = logger.child({ mod: "api" });
|
const log = logger.child({ mod: "api" });
|
||||||
const COOKIE_NAME = "mbot_session";
|
const COOKIE_NAME = "mbot_session";
|
||||||
const SEARCH_CACHE_TTL_MS = 15 * 60_000;
|
const SEARCH_CACHE_TTL_MS = 15 * 60_000;
|
||||||
const SEARCH_CACHE_LIMIT = 5000;
|
const SEARCH_CACHE_LIMIT = 5000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The panel never sends whole track objects back to us — it sends ids that were
|
* 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
|
* produced by a server-side search. That keeps clients from pointing playback at
|
||||||
* arbitrary local paths or internal URLs.
|
* arbitrary local paths or internal URLs.
|
||||||
*/
|
*/
|
||||||
const searchCache = new Map<string, { track: Track; at: number }>();
|
const searchCache = new Map<string, { track: Track; at: number }>();
|
||||||
|
|
||||||
function cacheTracks(tracks: Track[]): void {
|
function cacheTracks(tracks: Track[]): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const track of tracks) searchCache.set(track.id, { track, at: now });
|
for (const track of tracks) searchCache.set(track.id, { track, at: now });
|
||||||
if (searchCache.size > SEARCH_CACHE_LIMIT) {
|
if (searchCache.size > SEARCH_CACHE_LIMIT) {
|
||||||
for (const [id, entry] of searchCache) {
|
for (const [id, entry] of searchCache) {
|
||||||
if (now - entry.at > SEARCH_CACHE_TTL_MS) searchCache.delete(id);
|
if (now - entry.at > SEARCH_CACHE_TTL_MS) searchCache.delete(id);
|
||||||
if (searchCache.size <= SEARCH_CACHE_LIMIT) break;
|
if (searchCache.size <= SEARCH_CACHE_LIMIT) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function takeTracks(ids: string[]): Track[] {
|
function takeTracks(ids: string[]): Track[] {
|
||||||
const tracks: Track[] = [];
|
const tracks: Track[] = [];
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const entry = searchCache.get(id);
|
const entry = searchCache.get(id);
|
||||||
if (!entry || Date.now() - entry.at > SEARCH_CACHE_TTL_MS) continue;
|
if (!entry || Date.now() - entry.at > SEARCH_CACHE_TTL_MS) continue;
|
||||||
tracks.push(entry.track);
|
tracks.push(entry.track);
|
||||||
}
|
}
|
||||||
if (tracks.length === 0) throw new UserFacingError("Результаты поиска устарели, повторите поиск");
|
if (tracks.length === 0) throw new UserFacingError("Результаты поиска устарели, повторите поиск");
|
||||||
return tracks;
|
return tracks;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Session {
|
interface Session {
|
||||||
userId: string;
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "fastify" {
|
declare module "fastify" {
|
||||||
interface FastifyRequest {
|
interface FastifyRequest {
|
||||||
session?: Session;
|
session?: Session;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiServerOptions {
|
export interface ApiServerOptions {
|
||||||
manager: MusicManager;
|
manager: MusicManager;
|
||||||
context: BotStoatContext;
|
context: BotStoatContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startApiServer({ manager, context }: ApiServerOptions) {
|
export async function startApiServer({ manager, context }: ApiServerOptions) {
|
||||||
const app = Fastify({ logger: false, trustProxy: true });
|
const app = Fastify({ logger: false, trustProxy: true });
|
||||||
|
|
||||||
await app.register(cookie);
|
await app.register(cookie);
|
||||||
await app.register(websocket);
|
await app.register(websocket);
|
||||||
|
|
||||||
const secureCookies = config.PUBLIC_URL.startsWith("https://");
|
const secureCookies = config.PUBLIC_URL.startsWith("https://");
|
||||||
|
|
||||||
function setSessionCookie(reply: FastifyReply, token: string): void {
|
function setSessionCookie(reply: FastifyReply, token: string): void {
|
||||||
reply.setCookie(COOKIE_NAME, token, {
|
reply.setCookie(COOKIE_NAME, token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
secure: secureCookies,
|
secure: secureCookies,
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: config.SESSION_TTL_HOURS * 3600,
|
maxAge: config.SESSION_TTL_HOURS * 3600,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readSession(request: FastifyRequest): Promise<Session | null> {
|
async function readSession(request: FastifyRequest): Promise<Session | null> {
|
||||||
const token = request.cookies[COOKIE_NAME];
|
const token = request.cookies[COOKIE_NAME];
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
const claims = await verifyToken(token);
|
const claims = await verifyToken(token);
|
||||||
if (!claims || claims.typ !== "session") return null;
|
if (!claims || claims.typ !== "session") return null;
|
||||||
return { userId: claims.sub, username: claims.username };
|
return { userId: claims.sub, username: claims.username };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requireSession(request: FastifyRequest, reply: FastifyReply): Promise<Session | null> {
|
async function requireSession(request: FastifyRequest, reply: FastifyReply): Promise<Session | null> {
|
||||||
const session = await readSession(request);
|
const session = await readSession(request);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
await reply.code(401).send({ error: "Не авторизован" });
|
await reply.code(401).send({ error: "Не авторизован" });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
request.session = session;
|
request.session = session;
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Membership is re-checked on every request: roles change in Stoat, not here. */
|
/** Membership is re-checked on every request: roles change in Stoat, not here. */
|
||||||
async function requireServerAccess(
|
async function requireServerAccess(
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply,
|
||||||
serverId: string,
|
serverId: string,
|
||||||
): Promise<Session | null> {
|
): Promise<Session | null> {
|
||||||
const session = await requireSession(request, reply);
|
const session = await requireSession(request, reply);
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
if (!(await context.isMember(serverId, session.userId))) {
|
if (!(await context.isMember(serverId, session.userId))) {
|
||||||
await reply.code(403).send({ error: "Нет доступа к этому серверу" });
|
await reply.code(403).send({ error: "Нет доступа к этому серверу" });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.setErrorHandler((error, _request, reply) => {
|
app.setErrorHandler((error, _request, reply) => {
|
||||||
if (error instanceof UserFacingError) {
|
if (error instanceof UserFacingError) {
|
||||||
void reply.code(400).send({ error: error.message });
|
void reply.code(400).send({ error: error.message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ((error as { validation?: unknown }).validation) {
|
if ((error as { validation?: unknown }).validation) {
|
||||||
void reply.code(400).send({ error: "Некорректный запрос" });
|
void reply.code(400).send({ error: "Некорректный запрос" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log.error({ err: error }, "request failed");
|
log.error({ err: error }, "request failed");
|
||||||
void reply.code(500).send({ error: "Внутренняя ошибка" });
|
void reply.code(500).send({ error: "Внутренняя ошибка" });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------------------------------------------------ auth ---
|
// ------------------------------------------------------------------ auth ---
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
email: z.string().min(1).optional(),
|
email: z.string().min(1).optional(),
|
||||||
password: z.string().min(1).optional(),
|
password: z.string().min(1).optional(),
|
||||||
mfaTicket: z.string().optional(),
|
mfaTicket: z.string().optional(),
|
||||||
totpCode: z.string().optional(),
|
totpCode: z.string().optional(),
|
||||||
recoveryCode: z.string().optional(),
|
recoveryCode: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/auth/login", async (request, reply) => {
|
app.post("/api/auth/login", async (request, reply) => {
|
||||||
const body = loginSchema.parse(request.body ?? {});
|
const body = loginSchema.parse(request.body ?? {});
|
||||||
const mfa = body.mfaTicket
|
const mfa = body.mfaTicket
|
||||||
? { ticket: body.mfaTicket, totpCode: body.totpCode, recoveryCode: body.recoveryCode }
|
? { ticket: body.mfaTicket, totpCode: body.totpCode, recoveryCode: body.recoveryCode }
|
||||||
: undefined;
|
: undefined;
|
||||||
if (!mfa && (!body.email || !body.password)) {
|
if (!mfa && (!body.email || !body.password)) {
|
||||||
throw new UserFacingError("Укажите e-mail и пароль");
|
throw new UserFacingError("Укажите e-mail и пароль");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await loginWithPassword(body.email ?? "", body.password ?? "", mfa);
|
const result = await loginWithPassword(body.email ?? "", body.password ?? "", mfa);
|
||||||
if (result.kind === "mfa") {
|
if (result.kind === "mfa") {
|
||||||
return reply.send({ mfaRequired: true, ticket: result.ticket, methods: result.methods });
|
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.
|
// We only needed the session to prove who the user is; drop it immediately.
|
||||||
const profile = await fetchSelf(result.token).catch(() => null);
|
const profile = await fetchSelf(result.token).catch(() => null);
|
||||||
await revokeSession(result.token);
|
await revokeSession(result.token);
|
||||||
|
|
||||||
const username = profile?.display_name || profile?.username || "user";
|
const username = profile?.display_name || profile?.username || "user";
|
||||||
const token = await signSession(result.userId, username);
|
const token = await signSession(result.userId, username);
|
||||||
setSessionCookie(reply, token);
|
setSessionCookie(reply, token);
|
||||||
return reply.send({ user: { id: result.userId, username } });
|
return reply.send({ user: { id: result.userId, username } });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/auth/link", async (request, reply) => {
|
app.post("/api/auth/link", async (request, reply) => {
|
||||||
const body = z.object({ token: z.string().min(1) }).parse(request.body ?? {});
|
const body = z.object({ token: z.string().min(1) }).parse(request.body ?? {});
|
||||||
const claims = await verifyToken(body.token);
|
const claims = await verifyToken(body.token);
|
||||||
if (!claims || claims.typ !== "link") throw new UserFacingError("Ссылка недействительна или устарела");
|
if (!claims || claims.typ !== "link") throw new UserFacingError("Ссылка недействительна или устарела");
|
||||||
const token = await signSession(claims.sub, claims.username);
|
const token = await signSession(claims.sub, claims.username);
|
||||||
setSessionCookie(reply, token);
|
setSessionCookie(reply, token);
|
||||||
return reply.send({ user: { id: claims.sub, username: claims.username }, serverId: claims.srv ?? null });
|
return reply.send({ user: { id: claims.sub, username: claims.username }, serverId: claims.srv ?? null });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/auth/logout", async (_request, reply) => {
|
app.post("/api/auth/logout", async (_request, reply) => {
|
||||||
reply.clearCookie(COOKIE_NAME, { path: "/" });
|
reply.clearCookie(COOKIE_NAME, { path: "/" });
|
||||||
return reply.send({ ok: true });
|
return reply.send({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/me", async (request, reply) => {
|
app.get("/api/me", async (request, reply) => {
|
||||||
const session = await requireSession(request, reply);
|
const session = await requireSession(request, reply);
|
||||||
if (!session) return reply;
|
if (!session) return reply;
|
||||||
const servers = await context.listServersForUser(session.userId);
|
const servers = await context.listServersForUser(session.userId);
|
||||||
return reply.send({
|
return reply.send({
|
||||||
user: { id: session.userId, username: session.username },
|
user: { id: session.userId, username: session.username },
|
||||||
servers,
|
servers,
|
||||||
features: { localLibrary: Boolean(config.LOCAL_MEDIA_DIR) },
|
features: {
|
||||||
});
|
localLibrary: Boolean(config.LOCAL_MEDIA_DIR),
|
||||||
});
|
requireListener: config.REQUIRE_LISTENER,
|
||||||
|
},
|
||||||
// --------------------------------------------------------------- player ---
|
});
|
||||||
|
});
|
||||||
const serverParams = z.object({ id: z.string().min(1) });
|
|
||||||
|
// --------------------------------------------------------------- player ---
|
||||||
app.get("/api/servers/:id/state", async (request, reply) => {
|
|
||||||
const { id } = serverParams.parse(request.params);
|
const serverParams = z.object({ id: z.string().min(1) });
|
||||||
const session = await requireServerAccess(request, reply, id);
|
|
||||||
if (!session) return reply;
|
app.get("/api/servers/:id/state", async (request, reply) => {
|
||||||
return reply.send({
|
const { id } = serverParams.parse(request.params);
|
||||||
state: manager.snapshot(id),
|
const session = await requireServerAccess(request, reply, id);
|
||||||
voiceChannels: context.listVoiceChannels(id),
|
if (!session) return reply;
|
||||||
yourVoiceChannel: context.findUserVoiceChannel(id, session.userId),
|
return reply.send({
|
||||||
canControl: await context.canControl(id, session.userId),
|
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;
|
app.get("/api/servers/:id/search", async (request, reply) => {
|
||||||
const { q } = z.object({ q: z.string().min(1) }).parse(request.query);
|
const { id } = serverParams.parse(request.params);
|
||||||
const tracks = await manager.search(q, { id: session.userId, username: session.username });
|
const session = await requireServerAccess(request, reply, id);
|
||||||
cacheTracks(tracks);
|
if (!session) return reply;
|
||||||
return reply.send({ tracks });
|
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);
|
||||||
const playSchema = z.object({
|
return reply.send({ tracks });
|
||||||
query: z.string().min(1).optional(),
|
});
|
||||||
trackIds: z.array(z.string()).optional(),
|
|
||||||
mode: z.enum(["append", "next", "now"]).default("append"),
|
const playSchema = z.object({
|
||||||
voiceChannelId: z.string().nullable().optional(),
|
query: z.string().min(1).optional(),
|
||||||
});
|
trackIds: z.array(z.string()).optional(),
|
||||||
|
mode: z.enum(["append", "next", "now"]).default("append"),
|
||||||
app.post("/api/servers/:id/play", async (request, reply) => {
|
voiceChannelId: z.string().nullable().optional(),
|
||||||
const { id } = serverParams.parse(request.params);
|
});
|
||||||
const session = await requireServerAccess(request, reply, id);
|
|
||||||
if (!session) return reply;
|
app.post("/api/servers/:id/play", async (request, reply) => {
|
||||||
const body = playSchema.parse(request.body ?? {});
|
const { id } = serverParams.parse(request.params);
|
||||||
const requester = { id: session.userId, username: session.username };
|
const session = await requireServerAccess(request, reply, id);
|
||||||
|
if (!session) return reply;
|
||||||
const outcome = body.trackIds?.length
|
const body = playSchema.parse(request.body ?? {});
|
||||||
? await manager.enqueueTracks(id, requester, takeTracks(body.trackIds), {
|
const requester = { id: session.userId, username: session.username };
|
||||||
mode: body.mode,
|
|
||||||
voiceChannelId: body.voiceChannelId ?? null,
|
const outcome = body.trackIds?.length
|
||||||
})
|
? await manager.enqueueTracks(id, requester, takeTracks(body.trackIds), {
|
||||||
: await manager.play(id, requester, body.query ?? "", {
|
mode: body.mode,
|
||||||
mode: body.mode,
|
voiceChannelId: body.voiceChannelId ?? null,
|
||||||
voiceChannelId: body.voiceChannelId ?? null,
|
})
|
||||||
});
|
: await manager.play(id, requester, body.query ?? "", {
|
||||||
|
mode: body.mode,
|
||||||
return reply.send({ ok: true, added: outcome.tracks.length, state: manager.snapshot(id) });
|
voiceChannelId: body.voiceChannelId ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const actions: Record<string, (serverId: string, userId: string, body: unknown) => Promise<unknown>> = {
|
return reply.send({ ok: true, added: outcome.tracks.length, state: manager.snapshot(id) });
|
||||||
pause: (serverId, userId) => manager.pause(serverId, userId),
|
});
|
||||||
resume: (serverId, userId) => manager.resume(serverId, userId),
|
|
||||||
toggle: (serverId, userId) => manager.togglePause(serverId, userId),
|
const actions: Record<string, (serverId: string, userId: string, body: unknown) => Promise<unknown>> = {
|
||||||
skip: (serverId, userId, body) =>
|
pause: (serverId, userId) => manager.pause(serverId, userId),
|
||||||
manager.skip(serverId, userId, z.object({ count: z.number().int().min(1).default(1) }).parse(body ?? {}).count),
|
resume: (serverId, userId) => manager.resume(serverId, userId),
|
||||||
stop: (serverId, userId) => manager.stop(serverId, userId),
|
toggle: (serverId, userId) => manager.togglePause(serverId, userId),
|
||||||
shuffle: (serverId, userId) => manager.shuffle(serverId, userId),
|
skip: (serverId, userId, body) =>
|
||||||
clear: (serverId, userId) => manager.clearQueue(serverId, userId),
|
manager.skip(serverId, userId, z.object({ count: z.number().int().min(1).default(1) }).parse(body ?? {}).count),
|
||||||
leave: (serverId, userId) => manager.leave(serverId, userId),
|
stop: (serverId, userId) => manager.stop(serverId, userId),
|
||||||
volume: (serverId, userId, body) =>
|
shuffle: (serverId, userId) => manager.shuffle(serverId, userId),
|
||||||
manager.setVolume(serverId, userId, z.object({ volume: z.number().min(0).max(200) }).parse(body).volume),
|
clear: (serverId, userId) => manager.clearQueue(serverId, userId),
|
||||||
loop: (serverId, userId, body) =>
|
leave: (serverId, userId) => manager.leave(serverId, userId),
|
||||||
manager.setLoop(serverId, userId, z.object({ mode: z.enum(["off", "track", "queue"]) }).parse(body).mode),
|
volume: (serverId, userId, body) =>
|
||||||
seek: (serverId, userId, body) =>
|
manager.setVolume(serverId, userId, z.object({ volume: z.number().min(0).max(200) }).parse(body).volume),
|
||||||
manager.seek(serverId, userId, z.object({ position: z.number().min(0) }).parse(body).position),
|
loop: (serverId, userId, body) =>
|
||||||
};
|
manager.setLoop(serverId, userId, z.object({ mode: z.enum(["off", "track", "queue"]) }).parse(body).mode),
|
||||||
|
seek: (serverId, userId, body) =>
|
||||||
app.post("/api/servers/:id/actions/:action", async (request, reply) => {
|
manager.seek(serverId, userId, z.object({ position: z.number().min(0) }).parse(body).position),
|
||||||
const { id } = serverParams.parse(request.params);
|
};
|
||||||
const { action } = z.object({ action: z.string() }).parse(request.params);
|
|
||||||
const session = await requireServerAccess(request, reply, id);
|
app.post("/api/servers/:id/actions/:action", async (request, reply) => {
|
||||||
if (!session) return reply;
|
const { id } = serverParams.parse(request.params);
|
||||||
|
const { action } = z.object({ action: z.string() }).parse(request.params);
|
||||||
const handler = actions[action];
|
const session = await requireServerAccess(request, reply, id);
|
||||||
if (!handler) return reply.code(404).send({ error: "Неизвестное действие" });
|
if (!session) return reply;
|
||||||
|
|
||||||
const result = await handler(id, session.userId, request.body);
|
const handler = actions[action];
|
||||||
return reply.send({ ok: true, result: result ?? null, state: manager.snapshot(id) });
|
if (!handler) return reply.code(404).send({ error: "Неизвестное действие" });
|
||||||
});
|
|
||||||
|
const result = await handler(id, session.userId, request.body);
|
||||||
app.post("/api/servers/:id/join", async (request, reply) => {
|
return reply.send({ ok: true, result: result ?? null, state: manager.snapshot(id) });
|
||||||
const { id } = serverParams.parse(request.params);
|
});
|
||||||
const session = await requireServerAccess(request, reply, id);
|
|
||||||
if (!session) return reply;
|
app.post("/api/servers/:id/join", async (request, reply) => {
|
||||||
const body = z.object({ voiceChannelId: z.string().nullable().optional() }).parse(request.body ?? {});
|
const { id } = serverParams.parse(request.params);
|
||||||
await manager.assertControl(id, session.userId);
|
const session = await requireServerAccess(request, reply, id);
|
||||||
await manager.connect(id, session.userId, { voiceChannelId: body.voiceChannelId ?? null });
|
if (!session) return reply;
|
||||||
return reply.send({ ok: true, state: manager.snapshot(id) });
|
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 });
|
||||||
app.delete("/api/servers/:id/queue/:trackId", async (request, reply) => {
|
return reply.send({ ok: true, state: manager.snapshot(id) });
|
||||||
const { id } = serverParams.parse(request.params);
|
});
|
||||||
const { trackId } = z.object({ trackId: z.string() }).parse(request.params);
|
|
||||||
const session = await requireServerAccess(request, reply, id);
|
app.delete("/api/servers/:id/queue/:trackId", async (request, reply) => {
|
||||||
if (!session) return reply;
|
const { id } = serverParams.parse(request.params);
|
||||||
await manager.remove(id, session.userId, trackId);
|
const { trackId } = z.object({ trackId: z.string() }).parse(request.params);
|
||||||
return reply.send({ ok: true, state: manager.snapshot(id) });
|
const session = await requireServerAccess(request, reply, id);
|
||||||
});
|
if (!session) return reply;
|
||||||
|
await manager.remove(id, session.userId, trackId);
|
||||||
app.post("/api/servers/:id/queue/:trackId/move", async (request, reply) => {
|
return reply.send({ ok: true, state: manager.snapshot(id) });
|
||||||
const { id } = serverParams.parse(request.params);
|
});
|
||||||
const { trackId } = z.object({ trackId: z.string() }).parse(request.params);
|
|
||||||
const session = await requireServerAccess(request, reply, id);
|
app.post("/api/servers/:id/queue/:trackId/move", async (request, reply) => {
|
||||||
if (!session) return reply;
|
const { id } = serverParams.parse(request.params);
|
||||||
const { index } = z.object({ index: z.number().int().min(0) }).parse(request.body);
|
const { trackId } = z.object({ trackId: z.string() }).parse(request.params);
|
||||||
await manager.move(id, session.userId, trackId, index);
|
const session = await requireServerAccess(request, reply, id);
|
||||||
return reply.send({ ok: true, state: manager.snapshot(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);
|
||||||
// ----------------------------------------------------------- websockets ---
|
return reply.send({ ok: true, state: manager.snapshot(id) });
|
||||||
|
});
|
||||||
const subscribers = new Map<string, Set<{ send(data: string): void }>>();
|
|
||||||
|
// ----------------------------------------------------------- websockets ---
|
||||||
function broadcast(serverId: string, payload: unknown): void {
|
|
||||||
const listeners = subscribers.get(serverId);
|
const subscribers = new Map<string, Set<{ send(data: string): void }>>();
|
||||||
if (!listeners?.size) return;
|
|
||||||
const message = JSON.stringify(payload);
|
function broadcast(serverId: string, payload: unknown): void {
|
||||||
for (const socket of listeners) {
|
const listeners = subscribers.get(serverId);
|
||||||
try {
|
if (!listeners?.size) return;
|
||||||
socket.send(message);
|
const message = JSON.stringify(payload);
|
||||||
} catch {
|
for (const socket of listeners) {
|
||||||
listeners.delete(socket);
|
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) => {
|
manager.on("update", (snapshot) => broadcast(snapshot.serverId, { type: "state", state: snapshot }));
|
||||||
void (async () => {
|
manager.on("position", (position) => broadcast(position.serverId, { type: "position", ...position }));
|
||||||
const session = await readSession(request);
|
|
||||||
const serverId = (request.query as { server?: string }).server;
|
app.get("/ws", { websocket: true }, (socket, request) => {
|
||||||
if (!session || !serverId || !(await context.isMember(serverId, session.userId))) {
|
void (async () => {
|
||||||
socket.close(4001, "unauthorized");
|
const session = await readSession(request);
|
||||||
return;
|
const serverId = (request.query as { server?: string }).server;
|
||||||
}
|
if (!session || !serverId || !(await context.isMember(serverId, session.userId))) {
|
||||||
|
socket.close(4001, "unauthorized");
|
||||||
const listeners = subscribers.get(serverId) ?? new Set();
|
return;
|
||||||
listeners.add(socket);
|
}
|
||||||
subscribers.set(serverId, listeners);
|
|
||||||
|
const listeners = subscribers.get(serverId) ?? new Set();
|
||||||
socket.send(JSON.stringify({ type: "state", state: manager.snapshot(serverId) }));
|
listeners.add(socket);
|
||||||
socket.on("close", () => {
|
subscribers.set(serverId, listeners);
|
||||||
listeners.delete(socket);
|
|
||||||
if (listeners.size === 0) subscribers.delete(serverId);
|
socket.send(JSON.stringify({ type: "state", state: manager.snapshot(serverId) }));
|
||||||
});
|
|
||||||
})();
|
// Stoat's SDK keeps voice participants up to date but emits no event for
|
||||||
});
|
// them, so we watch our own view and push when this viewer's presence
|
||||||
|
// changes — otherwise the panel only learns about it on reload.
|
||||||
// --------------------------------------------------------------- static ---
|
let lastPresence = "";
|
||||||
|
const sendPresence = () => {
|
||||||
const webRoot = path.resolve(fileURLToPath(new URL("../..", import.meta.url)), "web/dist");
|
const yourVoiceChannel = context.findUserVoiceChannel(serverId, session.userId);
|
||||||
if (existsSync(webRoot)) {
|
const voiceChannels = context.listVoiceChannels(serverId);
|
||||||
await app.register(fastifyStatic, { root: webRoot });
|
const fingerprint = JSON.stringify([yourVoiceChannel, voiceChannels]);
|
||||||
app.setNotFoundHandler((request, reply) => {
|
if (fingerprint === lastPresence) return;
|
||||||
if (request.url.startsWith("/api") || request.url.startsWith("/ws")) {
|
lastPresence = fingerprint;
|
||||||
return reply.code(404).send({ error: "Not found" });
|
socket.send(JSON.stringify({ type: "presence", yourVoiceChannel, voiceChannels }));
|
||||||
}
|
};
|
||||||
return reply.sendFile("index.html");
|
sendPresence();
|
||||||
});
|
const presenceTimer = setInterval(sendPresence, 3000);
|
||||||
} else {
|
presenceTimer.unref?.();
|
||||||
log.warn({ webRoot }, "web/dist not found — panel UI is not served (run npm run web:build)");
|
|
||||||
}
|
socket.on("close", () => {
|
||||||
|
clearInterval(presenceTimer);
|
||||||
await app.listen({ port: config.PORT, host: config.HOST });
|
listeners.delete(socket);
|
||||||
log.info({ port: config.PORT, url: config.PUBLIC_URL }, "panel is listening");
|
if (listeners.size === 0) subscribers.delete(serverId);
|
||||||
return app;
|
});
|
||||||
}
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- 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;
|
||||||
|
}
|
||||||
|
|||||||
+60
-52
@@ -1,52 +1,60 @@
|
|||||||
import type { LoopMode, Track } from "../types.js";
|
import type { LoopMode, Track } from "../types.js";
|
||||||
|
|
||||||
export function formatDuration(seconds: number): string {
|
/** Clock formatting for any position or length; 0 is a legitimate "0:00". */
|
||||||
if (!Number.isFinite(seconds) || seconds <= 0) return "LIVE";
|
export function formatDuration(seconds: number): string {
|
||||||
const total = Math.floor(seconds);
|
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
|
||||||
const hours = Math.floor(total / 3600);
|
const total = Math.floor(seconds);
|
||||||
const minutes = Math.floor((total % 3600) / 60);
|
const hours = Math.floor(total / 3600);
|
||||||
const secs = total % 60;
|
const minutes = Math.floor((total % 3600) / 60);
|
||||||
const pad = (value: number) => value.toString().padStart(2, "0");
|
const secs = total % 60;
|
||||||
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`;
|
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();
|
/** Track length as shown to people: live streams and unknown lengths are not clocks. */
|
||||||
if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10);
|
export function formatLength(track: { duration: number; isLive: boolean }): string {
|
||||||
const match = /^(?:(\d+):)?(\d{1,2}):(\d{1,2})$/.exec(trimmed);
|
if (track.isLive) return "LIVE";
|
||||||
if (!match) return null;
|
if (track.duration <= 0) return "—";
|
||||||
const [, hours, minutes, seconds] = match;
|
return formatDuration(track.duration);
|
||||||
return (
|
}
|
||||||
Number.parseInt(hours ?? "0", 10) * 3600 +
|
|
||||||
Number.parseInt(minutes ?? "0", 10) * 60 +
|
export function parseTimecode(input: string): number | null {
|
||||||
Number.parseInt(seconds ?? "0", 10)
|
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;
|
||||||
export function progressBar(position: number, duration: number, width = 22): string {
|
const [, hours, minutes, seconds] = match;
|
||||||
if (duration <= 0) return "🔴 прямой эфир";
|
return (
|
||||||
const ratio = Math.min(1, Math.max(0, position / duration));
|
Number.parseInt(hours ?? "0", 10) * 3600 +
|
||||||
const filled = Math.round(ratio * (width - 1));
|
Number.parseInt(minutes ?? "0", 10) * 60 +
|
||||||
const bar = `${"─".repeat(filled)}⬤${"─".repeat(Math.max(0, width - 1 - filled))}`;
|
Number.parseInt(seconds ?? "0", 10)
|
||||||
return `\`${formatDuration(position)}\` ${bar} \`${formatDuration(duration)}\``;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SOURCE_LABEL: Record<Track["source"], string> = {
|
export function progressBar(position: number, duration: number, width = 22): string {
|
||||||
youtube: "YouTube",
|
if (duration <= 0) return "🔴 прямой эфир";
|
||||||
soundcloud: "SoundCloud",
|
const ratio = Math.min(1, Math.max(0, position / duration));
|
||||||
direct: "Ссылка",
|
const filled = Math.round(ratio * (width - 1));
|
||||||
local: "Медиатека",
|
const bar = `${"─".repeat(filled)}⬤${"─".repeat(Math.max(0, width - 1 - filled))}`;
|
||||||
};
|
return `\`${formatDuration(position)}\` ${bar} \`${formatDuration(duration)}\``;
|
||||||
|
}
|
||||||
export function trackLine(track: Track, index?: number): string {
|
|
||||||
const prefix = index === undefined ? "" : `**${index}.** `;
|
const SOURCE_LABEL: Record<Track["source"], string> = {
|
||||||
const author = track.author ? ` — ${track.author}` : "";
|
youtube: "YouTube",
|
||||||
const link = /^https?:/i.test(track.url) ? `[${track.title}](${track.url})` : track.title;
|
soundcloud: "SoundCloud",
|
||||||
return `${prefix}${link}${author} \`[${formatDuration(track.duration)}]\` · ${SOURCE_LABEL[track.source]} · <@${track.requestedBy.id}>`;
|
direct: "Ссылка",
|
||||||
}
|
local: "Медиатека",
|
||||||
|
};
|
||||||
export function loopLabel(mode: LoopMode): string {
|
|
||||||
if (mode === "track") return "трек";
|
export function trackLine(track: Track, index?: number): string {
|
||||||
if (mode === "queue") return "очередь";
|
const prefix = index === undefined ? "" : `**${index}.** `;
|
||||||
return "выключен";
|
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 "выключен";
|
||||||
|
}
|
||||||
|
|||||||
+7
-1
@@ -50,8 +50,14 @@ const schema = z.object({
|
|||||||
DEFAULT_VOLUME: z.coerce.number().min(0).max(200).default(60),
|
DEFAULT_VOLUME: z.coerce.number().min(0).max(200).default(60),
|
||||||
MAX_QUEUE_SIZE: z.coerce.number().int().positive().default(500),
|
MAX_QUEUE_SIZE: z.coerce.number().int().positive().default(500),
|
||||||
SEARCH_RESULT_LIMIT: z.coerce.number().int().positive().max(25).default(10),
|
SEARCH_RESULT_LIMIT: z.coerce.number().int().positive().max(25).default(10),
|
||||||
IDLE_TIMEOUT_SECONDS: z.coerce.number().int().min(0).default(300),
|
/** Seconds to wait after the last human leaves the voice channel (0 — never leave). */
|
||||||
|
EMPTY_TIMEOUT_SECONDS: z.coerce.number().int().min(0).default(120),
|
||||||
DJ_ROLE_NAME: z.string().default("DJ"),
|
DJ_ROLE_NAME: z.string().default("DJ"),
|
||||||
|
/** Only let people summon the bot into the voice channel they are sitting in. */
|
||||||
|
REQUIRE_LISTENER: z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.default("true")
|
||||||
|
.transform((value) => value === "true"),
|
||||||
REQUIRE_DJ_ROLE: z
|
REQUIRE_DJ_ROLE: z
|
||||||
.enum(["true", "false"])
|
.enum(["true", "false"])
|
||||||
.default("false")
|
.default("false")
|
||||||
|
|||||||
+16
-4
@@ -141,10 +141,22 @@ export class MusicManager extends EventEmitter<ManagerEvents> {
|
|||||||
const player = this.getOrCreate(serverId);
|
const player = this.getOrCreate(serverId);
|
||||||
if (options.textChannelId) player.textChannelId = options.textChannelId;
|
if (options.textChannelId) player.textChannelId = options.textChannelId;
|
||||||
|
|
||||||
const target = options.voiceChannelId
|
const listening = this.chat.findUserVoiceChannel(serverId, userId);
|
||||||
? this.chat.getVoiceChannel(options.voiceChannelId)
|
|
||||||
: (this.chat.findUserVoiceChannel(serverId, userId) ??
|
if (config.REQUIRE_LISTENER) {
|
||||||
(player.voiceChannelId ? this.chat.getVoiceChannel(player.voiceChannelId) : null));
|
// Music follows the listener: you cannot push the bot into a channel you
|
||||||
|
// are not sitting in, and you cannot start playback from nowhere.
|
||||||
|
if (!listening) throw new UserFacingError("Сначала зайдите в голосовой канал");
|
||||||
|
if (options.voiceChannelId && options.voiceChannelId !== listening.id) {
|
||||||
|
throw new UserFacingError("Бота можно позвать только в тот канал, где вы находитесь");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = config.REQUIRE_LISTENER
|
||||||
|
? listening
|
||||||
|
: (options.voiceChannelId
|
||||||
|
? this.chat.getVoiceChannel(options.voiceChannelId)
|
||||||
|
: (listening ?? (player.voiceChannelId ? this.chat.getVoiceChannel(player.voiceChannelId) : null)));
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
throw new UserFacingError("Зайдите в голосовой канал или укажите его явно");
|
throw new UserFacingError("Зайдите в голосовой канал или укажите его явно");
|
||||||
|
|||||||
+26
-20
@@ -77,7 +77,7 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
|
|||||||
private seekOffset = 0;
|
private seekOffset = 0;
|
||||||
/** Set while we tear playback down ourselves, so the resulting `finish` is ignored. */
|
/** Set while we tear playback down ourselves, so the resulting `finish` is ignored. */
|
||||||
private expectingStop = false;
|
private expectingStop = false;
|
||||||
private idleTimer: NodeJS.Timeout | null = null;
|
private leaveTimer: NodeJS.Timeout | null = null;
|
||||||
private ticker: NodeJS.Timeout | null = null;
|
private ticker: NodeJS.Timeout | null = null;
|
||||||
private readonly log;
|
private readonly log;
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
|
|||||||
});
|
});
|
||||||
connection.on("userleave", () => this.checkEmptyChannel());
|
connection.on("userleave", () => this.checkEmptyChannel());
|
||||||
connection.on("userLeave", () => this.checkEmptyChannel());
|
connection.on("userLeave", () => this.checkEmptyChannel());
|
||||||
connection.on("userJoin", () => this.clearIdleTimer());
|
connection.on("userJoin", () => this.cancelLeaveTimer());
|
||||||
|
|
||||||
const media = new MediaPlayer(true);
|
const media = new MediaPlayer(true);
|
||||||
// revoice's #cleanUp() dereferences this.fProc unconditionally, so a second
|
// revoice's #cleanUp() dereferences this.fProc unconditionally, so a second
|
||||||
@@ -211,11 +211,12 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
|
|||||||
await connection.play(media);
|
await connection.play(media);
|
||||||
|
|
||||||
this.setStatus("idle");
|
this.setStatus("idle");
|
||||||
|
this.checkEmptyChannel();
|
||||||
this.log.info({ channelId }, "voice connection established");
|
this.log.info({ channelId }, "voice connection established");
|
||||||
}
|
}
|
||||||
|
|
||||||
async leaveVoice(): Promise<void> {
|
async leaveVoice(): Promise<void> {
|
||||||
this.clearIdleTimer();
|
this.cancelLeaveTimer();
|
||||||
this.stopTicker();
|
this.stopTicker();
|
||||||
this.teardownPlayback();
|
this.teardownPlayback();
|
||||||
this.current = null;
|
this.current = null;
|
||||||
@@ -242,8 +243,11 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
|
|||||||
|
|
||||||
private checkEmptyChannel(): void {
|
private checkEmptyChannel(): void {
|
||||||
if (!this.connection) return;
|
if (!this.connection) return;
|
||||||
if (this.connection.getUsers().length > 0) return;
|
if (this.connection.getUsers().length > 0) {
|
||||||
this.startIdleTimer("В канале никого не осталось");
|
this.cancelLeaveTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.startLeaveTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------- playback ---
|
// -------------------------------------------------------------- playback ---
|
||||||
@@ -272,7 +276,7 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
|
|||||||
|
|
||||||
private async startPlayback(track: Track, seekSeconds = 0): Promise<void> {
|
private async startPlayback(track: Track, seekSeconds = 0): Promise<void> {
|
||||||
const media = this.assertReady();
|
const media = this.assertReady();
|
||||||
this.clearIdleTimer();
|
this.cancelLeaveTimer();
|
||||||
this.teardownPlayback();
|
this.teardownPlayback();
|
||||||
|
|
||||||
this.current = track;
|
this.current = track;
|
||||||
@@ -361,7 +365,6 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
|
|||||||
this.setStatus("idle");
|
this.setStatus("idle");
|
||||||
this.publish();
|
this.publish();
|
||||||
if (finished) this.notify("⏹️ Очередь закончилась.");
|
if (finished) this.notify("⏹️ Очередь закончилась.");
|
||||||
this.startIdleTimer();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,7 +388,6 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
|
|||||||
this.stopTicker();
|
this.stopTicker();
|
||||||
this.setStatus("idle");
|
this.setStatus("idle");
|
||||||
this.publish();
|
this.publish();
|
||||||
this.startIdleTimer();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pause(): void {
|
pause(): void {
|
||||||
@@ -501,21 +503,25 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
|
|||||||
this.ticker = null;
|
this.ticker = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearIdleTimer(): void {
|
private cancelLeaveTimer(): void {
|
||||||
if (!this.idleTimer) return;
|
if (!this.leaveTimer) return;
|
||||||
clearTimeout(this.idleTimer);
|
clearTimeout(this.leaveTimer);
|
||||||
this.idleTimer = null;
|
this.leaveTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private startIdleTimer(reason?: string): void {
|
/**
|
||||||
this.clearIdleTimer();
|
* Leaving is tied to the channel being empty, never to an idle queue: the bot
|
||||||
if (config.IDLE_TIMEOUT_SECONDS <= 0 || !this.connection) return;
|
* stays put with people around, waiting for the next request.
|
||||||
this.idleTimer = setTimeout(() => {
|
*/
|
||||||
if (this.current) return;
|
private startLeaveTimer(): void {
|
||||||
this.notify(`👋 ${reason ?? "Нет активности"}, выхожу из голосового канала.`);
|
this.cancelLeaveTimer();
|
||||||
|
if (config.EMPTY_TIMEOUT_SECONDS <= 0 || !this.connection) return;
|
||||||
|
this.leaveTimer = setTimeout(() => {
|
||||||
|
if (this.connection && this.connection.getUsers().length > 0) return;
|
||||||
|
this.notify("👋 В канале никого не осталось, выхожу.");
|
||||||
void this.leaveVoice();
|
void this.leaveVoice();
|
||||||
}, config.IDLE_TIMEOUT_SECONDS * 1000);
|
}, config.EMPTY_TIMEOUT_SECONDS * 1000);
|
||||||
this.idleTimer.unref?.();
|
this.leaveTimer.unref?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
async destroy(): Promise<void> {
|
async destroy(): Promise<void> {
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.env["IDLE_TIMEOUT_SECONDS"]) {
|
||||||
|
logger.warn(
|
||||||
|
"IDLE_TIMEOUT_SECONDS is gone: the bot now leaves only when the voice channel empties — use EMPTY_TIMEOUT_SECONDS",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const cookies = await checkCookies();
|
const cookies = await checkCookies();
|
||||||
if (cookies === "ok") {
|
if (cookies === "ok") {
|
||||||
logger.info({ path: config.YTDLP_COOKIES }, "using YouTube cookies");
|
logger.info({ path: config.YTDLP_COOKIES }, "using YouTube cookies");
|
||||||
|
|||||||
+23
-9
@@ -61,8 +61,14 @@ export async function checkCookies(): Promise<CookieStatus | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function runYtDlp(args: string[], timeoutMs = 45_000): Promise<string> {
|
interface YtDlpRun {
|
||||||
return new Promise((resolve, reject) => {
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
code: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runYtDlp(args: string[], timeoutMs = 45_000): Promise<YtDlpRun> {
|
||||||
|
return new Promise<YtDlpRun>((resolve, reject) => {
|
||||||
const child = spawn(config.YTDLP_PATH, args, { windowsHide: true });
|
const child = spawn(config.YTDLP_PATH, args, { windowsHide: true });
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
@@ -88,7 +94,7 @@ function runYtDlp(args: string[], timeoutMs = 45_000): Promise<string> {
|
|||||||
child.on("close", (code) => {
|
child.on("close", (code) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
if (code === 0 || stdout.trim().length > 0) {
|
if (code === 0 || stdout.trim().length > 0) {
|
||||||
resolve(stdout);
|
resolve({ stdout, stderr, code });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log.warn({ code, stderr: stderr.slice(0, 800) }, "yt-dlp failed");
|
log.warn({ code, stderr: stderr.slice(0, 800) }, "yt-dlp failed");
|
||||||
@@ -160,18 +166,26 @@ export async function search(
|
|||||||
requestedBy: Requester,
|
requestedBy: Requester,
|
||||||
): Promise<Track[]> {
|
): Promise<Track[]> {
|
||||||
const prefix = kind === "soundcloud" ? "scsearch" : "ytsearch";
|
const prefix = kind === "soundcloud" ? "scsearch" : "ytsearch";
|
||||||
const stdout = await runYtDlp([
|
const { stdout, stderr } = await runYtDlp([
|
||||||
...baseArgs(),
|
...baseArgs(),
|
||||||
"--flat-playlist",
|
"--flat-playlist",
|
||||||
"--dump-json",
|
"--dump-json",
|
||||||
`${prefix}${limit}:${query}`,
|
`${prefix}${limit}:${query}`,
|
||||||
]);
|
]);
|
||||||
return parseNdjson(stdout).map((entry) => toTrack(entry, requestedBy, kind));
|
const entries = parseNdjson(stdout);
|
||||||
|
// yt-dlp can exit 0 with nothing to show (bot checks, region blocks). Without
|
||||||
|
// this the panel would just render an empty list and say nothing at all.
|
||||||
|
if (entries.length === 0 && stderr.trim()) {
|
||||||
|
log.warn({ query, kind, stderr: stderr.slice(0, 500) }, "search returned nothing");
|
||||||
|
throw new UserFacingError(firstUsefulError(stderr));
|
||||||
|
}
|
||||||
|
log.info({ query, kind, count: entries.length }, "search");
|
||||||
|
return entries.map((entry) => toTrack(entry, requestedBy, kind));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolves a URL that may point at a single track, a playlist, or an album. */
|
/** Resolves a URL that may point at a single track, a playlist, or an album. */
|
||||||
export async function resolveUrl(url: string, requestedBy: Requester, maxTracks: number): Promise<SearchResult> {
|
export async function resolveUrl(url: string, requestedBy: Requester, maxTracks: number): Promise<SearchResult> {
|
||||||
const stdout = await runYtDlp([
|
const { stdout } = await runYtDlp([
|
||||||
...baseArgs(),
|
...baseArgs(),
|
||||||
"--flat-playlist",
|
"--flat-playlist",
|
||||||
"--dump-single-json",
|
"--dump-single-json",
|
||||||
@@ -200,7 +214,7 @@ export async function resolveUrl(url: string, requestedBy: Requester, maxTracks:
|
|||||||
|
|
||||||
/** Resolves a direct, time-limited media URL for a page URL (used when seeking). */
|
/** Resolves a direct, time-limited media URL for a page URL (used when seeking). */
|
||||||
export async function resolveStreamUrl(pageUrl: string): Promise<string> {
|
export async function resolveStreamUrl(pageUrl: string): Promise<string> {
|
||||||
const stdout = await runYtDlp([...baseArgs(), "-f", "bestaudio/best", "--no-playlist", "-g", pageUrl]);
|
const { stdout } = await runYtDlp([...baseArgs(), "-f", "bestaudio/best", "--no-playlist", "-g", pageUrl]);
|
||||||
const url = stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
|
const url = stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
|
||||||
if (!url) throw new UserFacingError("Не удалось получить ссылку на аудиопоток");
|
if (!url) throw new UserFacingError("Не удалось получить ссылку на аудиопоток");
|
||||||
return url;
|
return url;
|
||||||
@@ -252,8 +266,8 @@ export function openAudioStream(pageUrl: string): AudioProcess {
|
|||||||
|
|
||||||
export async function checkAvailable(): Promise<string | null> {
|
export async function checkAvailable(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const out = await runYtDlp(["--version"], 15_000);
|
const { stdout } = await runYtDlp(["--version"], 15_000);
|
||||||
return out.trim() || null;
|
return stdout.trim() || null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+229
-223
@@ -1,223 +1,229 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
import { Login } from "./components/Login";
|
import { Login } from "./components/Login";
|
||||||
import { NowPlaying } from "./components/NowPlaying";
|
import { NowPlaying } from "./components/NowPlaying";
|
||||||
import { QueueList } from "./components/QueueList";
|
import { QueueList } from "./components/QueueList";
|
||||||
import { SearchPanel } from "./components/SearchPanel";
|
import { SearchPanel } from "./components/SearchPanel";
|
||||||
import type { Me, PlayerState, ServerStateResponse, VoiceChannel } from "./types";
|
import type { Me, PlayerState, ServerStateResponse, VoiceChannel } from "./types";
|
||||||
|
|
||||||
const SERVER_KEY = "mbot.server";
|
const SERVER_KEY = "mbot.server";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [me, setMe] = useState<Me | null>(null);
|
const [me, setMe] = useState<Me | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [serverId, setServerId] = useState<string | null>(null);
|
const [serverId, setServerId] = useState<string | null>(null);
|
||||||
const [state, setState] = useState<PlayerState | null>(null);
|
const [state, setState] = useState<PlayerState | null>(null);
|
||||||
const [position, setPosition] = useState(0);
|
const [position, setPosition] = useState(0);
|
||||||
const [voiceChannels, setVoiceChannels] = useState<VoiceChannel[]>([]);
|
const [voiceChannels, setVoiceChannels] = useState<VoiceChannel[]>([]);
|
||||||
const [yourVoiceChannel, setYourVoiceChannel] = useState<VoiceChannel | null>(null);
|
const [yourVoiceChannel, setYourVoiceChannel] = useState<VoiceChannel | null>(null);
|
||||||
const [canControl, setCanControl] = useState(false);
|
const [canControl, setCanControl] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const socketRef = useRef<WebSocket | null>(null);
|
const socketRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
/** Consumes the one-time link issued by the `!panel` chat command. */
|
/** Consumes the one-time link issued by the `!panel` chat command. */
|
||||||
const consumeLinkToken = useCallback(async (): Promise<string | null> => {
|
const consumeLinkToken = useCallback(async (): Promise<string | null> => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const token = params.get("token");
|
const token = params.get("token");
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
try {
|
try {
|
||||||
const result = await api.loginWithLink(token);
|
const result = await api.loginWithLink(token);
|
||||||
window.history.replaceState({}, "", "/");
|
window.history.replaceState({}, "", "/");
|
||||||
return result.serverId;
|
return result.serverId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Ссылка недействительна");
|
setError(err instanceof Error ? err.message : "Ссылка недействительна");
|
||||||
window.history.replaceState({}, "", "/");
|
window.history.replaceState({}, "", "/");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadMe = useCallback(
|
const loadMe = useCallback(
|
||||||
async (preferredServer?: string | null) => {
|
async (preferredServer?: string | null) => {
|
||||||
try {
|
try {
|
||||||
const profile = await api.me();
|
const profile = await api.me();
|
||||||
setMe(profile);
|
setMe(profile);
|
||||||
const stored = preferredServer ?? localStorage.getItem(SERVER_KEY);
|
const stored = preferredServer ?? localStorage.getItem(SERVER_KEY);
|
||||||
const chosen = profile.servers.find((server) => server.id === stored) ?? profile.servers[0];
|
const chosen = profile.servers.find((server) => server.id === stored) ?? profile.servers[0];
|
||||||
setServerId(chosen?.id ?? null);
|
setServerId(chosen?.id ?? null);
|
||||||
} catch {
|
} catch {
|
||||||
setMe(null);
|
setMe(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const fromLink = await consumeLinkToken();
|
const fromLink = await consumeLinkToken();
|
||||||
await loadMe(fromLink);
|
await loadMe(fromLink);
|
||||||
})();
|
})();
|
||||||
}, [consumeLinkToken, loadMe]);
|
}, [consumeLinkToken, loadMe]);
|
||||||
|
|
||||||
const applyServerState = useCallback((payload: ServerStateResponse) => {
|
const applyServerState = useCallback((payload: ServerStateResponse) => {
|
||||||
setState(payload.state);
|
setState(payload.state);
|
||||||
setPosition(payload.state.position);
|
setPosition(payload.state.position);
|
||||||
setVoiceChannels(payload.voiceChannels);
|
setVoiceChannels(payload.voiceChannels);
|
||||||
setYourVoiceChannel(payload.yourVoiceChannel);
|
setYourVoiceChannel(payload.yourVoiceChannel);
|
||||||
setCanControl(payload.canControl);
|
setCanControl(payload.canControl);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refresh = useCallback(
|
const refresh = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
try {
|
try {
|
||||||
applyServerState(await api.state(id));
|
applyServerState(await api.state(id));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Не удалось получить состояние");
|
setError(err instanceof Error ? err.message : "Не удалось получить состояние");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[applyServerState],
|
[applyServerState],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Live updates: the socket carries both full snapshots and 1 Hz position ticks.
|
// Live updates: the socket carries both full snapshots and 1 Hz position ticks.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverId) return;
|
if (!serverId) return;
|
||||||
localStorage.setItem(SERVER_KEY, serverId);
|
localStorage.setItem(SERVER_KEY, serverId);
|
||||||
void refresh(serverId);
|
void refresh(serverId);
|
||||||
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
const socket = new WebSocket(`${protocol}://${window.location.host}/ws?server=${serverId}`);
|
const socket = new WebSocket(`${protocol}://${window.location.host}/ws?server=${serverId}`);
|
||||||
socketRef.current = socket;
|
socketRef.current = socket;
|
||||||
|
|
||||||
socket.onmessage = (event) => {
|
socket.onmessage = (event) => {
|
||||||
const payload = JSON.parse(event.data as string) as
|
const payload = JSON.parse(event.data as string) as
|
||||||
| { type: "state"; state: PlayerState }
|
| { type: "state"; state: PlayerState }
|
||||||
| { type: "position"; position: number };
|
| { type: "position"; position: number }
|
||||||
if (payload.type === "state") {
|
| { type: "presence"; yourVoiceChannel: VoiceChannel | null; voiceChannels: VoiceChannel[] };
|
||||||
setState(payload.state);
|
if (payload.type === "state") {
|
||||||
setPosition(payload.state.position);
|
setState(payload.state);
|
||||||
} else {
|
setPosition(payload.state.position);
|
||||||
setPosition(payload.position);
|
} else if (payload.type === "presence") {
|
||||||
}
|
setYourVoiceChannel(payload.yourVoiceChannel);
|
||||||
};
|
setVoiceChannels(payload.voiceChannels);
|
||||||
|
} else {
|
||||||
return () => {
|
setPosition(payload.position);
|
||||||
socket.close();
|
}
|
||||||
socketRef.current = null;
|
};
|
||||||
};
|
|
||||||
}, [serverId, refresh]);
|
return () => {
|
||||||
|
socket.close();
|
||||||
const runAction = useCallback(
|
socketRef.current = null;
|
||||||
async (action: string, payload: Record<string, unknown> = {}) => {
|
};
|
||||||
if (!serverId) return;
|
}, [serverId, refresh]);
|
||||||
setError(null);
|
|
||||||
try {
|
const runAction = useCallback(
|
||||||
await api.action(serverId, action, payload);
|
async (action: string, payload: Record<string, unknown> = {}) => {
|
||||||
} catch (err) {
|
if (!serverId) return;
|
||||||
setError(err instanceof Error ? err.message : "Действие не выполнено");
|
setError(null);
|
||||||
}
|
try {
|
||||||
},
|
await api.action(serverId, action, payload);
|
||||||
[serverId],
|
} catch (err) {
|
||||||
);
|
setError(err instanceof Error ? err.message : "Действие не выполнено");
|
||||||
|
}
|
||||||
if (loading) return <div className="login-wrap">Загрузка…</div>;
|
},
|
||||||
if (!me) return <Login onSuccess={() => void loadMe()} />;
|
[serverId],
|
||||||
|
);
|
||||||
return (
|
|
||||||
<div className="app">
|
if (loading) return <div className="login-wrap">Загрузка…</div>;
|
||||||
<header className="topbar">
|
if (!me) return <Login onSuccess={() => void loadMe()} />;
|
||||||
<div className="brand">
|
|
||||||
<span className="dot" />
|
return (
|
||||||
Stoat Music
|
<div className="app">
|
||||||
</div>
|
<header className="topbar">
|
||||||
|
<div className="brand">
|
||||||
{me.servers.length > 0 && (
|
<span className="dot" />
|
||||||
<select value={serverId ?? ""} onChange={(event) => setServerId(event.target.value)}>
|
Stoat Music
|
||||||
{me.servers.map((server) => (
|
</div>
|
||||||
<option key={server.id} value={server.id}>
|
|
||||||
{server.name}
|
{me.servers.length > 0 && (
|
||||||
</option>
|
<select value={serverId ?? ""} onChange={(event) => setServerId(event.target.value)}>
|
||||||
))}
|
{me.servers.map((server) => (
|
||||||
</select>
|
<option key={server.id} value={server.id}>
|
||||||
)}
|
{server.name}
|
||||||
|
</option>
|
||||||
<div className="spacer" />
|
))}
|
||||||
<span className="who">{me.user.username}</span>
|
</select>
|
||||||
<button
|
)}
|
||||||
className="ghost"
|
|
||||||
onClick={async () => {
|
<div className="spacer" />
|
||||||
await api.logout();
|
<span className="who">{me.user.username}</span>
|
||||||
setMe(null);
|
<button
|
||||||
}}
|
className="ghost"
|
||||||
>
|
onClick={async () => {
|
||||||
Выйти
|
await api.logout();
|
||||||
</button>
|
setMe(null);
|
||||||
</header>
|
}}
|
||||||
|
>
|
||||||
{error && <div className="error">{error}</div>}
|
Выйти
|
||||||
|
</button>
|
||||||
{!serverId || !state ? (
|
</header>
|
||||||
<div className="card empty">
|
|
||||||
Бот не состоит ни в одном общем с вами сервере. Пригласите его и обновите страницу.
|
{error && <div className="error">{error}</div>}
|
||||||
</div>
|
|
||||||
) : (
|
{!serverId || !state ? (
|
||||||
<div className="layout">
|
<div className="card empty">
|
||||||
<div>
|
Бот не состоит ни в одном общем с вами сервере. Пригласите его и обновите страницу.
|
||||||
<SearchPanel
|
</div>
|
||||||
serverId={serverId}
|
) : (
|
||||||
canControl={canControl}
|
<div className="layout">
|
||||||
localLibrary={me.features.localLibrary}
|
<div>
|
||||||
onError={setError}
|
<SearchPanel
|
||||||
/>
|
serverId={serverId}
|
||||||
<QueueList
|
canControl={canControl}
|
||||||
tracks={state.queue}
|
localLibrary={me.features.localLibrary}
|
||||||
canControl={canControl}
|
onError={setError}
|
||||||
onRemove={(track) => {
|
/>
|
||||||
void api.removeTrack(serverId, track.id).catch((err: Error) => setError(err.message));
|
</div>
|
||||||
}}
|
|
||||||
onMove={(track, index) => {
|
<div>
|
||||||
void api.moveTrack(serverId, track.id, index).catch((err: Error) => setError(err.message));
|
<NowPlaying
|
||||||
}}
|
state={state}
|
||||||
onClear={() => void runAction("clear")}
|
position={position}
|
||||||
/>
|
canControl={canControl}
|
||||||
</div>
|
voiceChannels={voiceChannels}
|
||||||
|
yourVoiceChannel={yourVoiceChannel}
|
||||||
<div>
|
requireListener={me.features.requireListener}
|
||||||
<NowPlaying
|
onAction={(action, payload) => void runAction(action, payload)}
|
||||||
state={state}
|
onSeek={(seconds) => void runAction("seek", { position: seconds })}
|
||||||
position={position}
|
onJoin={(channelId) => {
|
||||||
canControl={canControl}
|
void api
|
||||||
voiceChannels={voiceChannels}
|
.join(serverId, channelId)
|
||||||
yourVoiceChannel={yourVoiceChannel}
|
.then(() => refresh(serverId))
|
||||||
onAction={(action, payload) => void runAction(action, payload)}
|
.catch((err: Error) => setError(err.message));
|
||||||
onSeek={(seconds) => void runAction("seek", { position: seconds })}
|
}}
|
||||||
onJoin={(channelId) => {
|
/>
|
||||||
void api
|
|
||||||
.join(serverId, channelId)
|
<QueueList
|
||||||
.then(() => refresh(serverId))
|
tracks={state.queue}
|
||||||
.catch((err: Error) => setError(err.message));
|
canControl={canControl}
|
||||||
}}
|
onRemove={(track) => {
|
||||||
/>
|
void api.removeTrack(serverId, track.id).catch((err: Error) => setError(err.message));
|
||||||
|
}}
|
||||||
{state.history.length > 0 && (
|
onMove={(track, index) => {
|
||||||
<div className="card">
|
void api.moveTrack(serverId, track.id, index).catch((err: Error) => setError(err.message));
|
||||||
<h2>История</h2>
|
}}
|
||||||
<ul className="track-list">
|
onClear={() => void runAction("clear")}
|
||||||
{state.history.map((track, index) => (
|
/>
|
||||||
<li className="track" key={`${track.id}-${index}`}>
|
|
||||||
<span className="idx">{index + 1}</span>
|
{state.history.length > 0 && (
|
||||||
<div className="info">
|
<div className="card">
|
||||||
<div className="title">{track.title}</div>
|
<h2>История</h2>
|
||||||
<div className="sub">{track.author ?? ""}</div>
|
<ul className="track-list">
|
||||||
</div>
|
{state.history.map((track, index) => (
|
||||||
</li>
|
<li className="track" key={`${track.id}-${index}`}>
|
||||||
))}
|
<span className="idx">{index + 1}</span>
|
||||||
</ul>
|
<div className="info">
|
||||||
</div>
|
<div className="title">{track.title}</div>
|
||||||
)}
|
<div className="sub">{track.author ?? ""}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
)}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
);
|
</div>
|
||||||
}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+91
-83
@@ -1,83 +1,91 @@
|
|||||||
import type { Me, ServerStateResponse, Track } from "./types";
|
import type { Me, ServerStateResponse, Track } from "./types";
|
||||||
|
|
||||||
export class ApiError extends Error {}
|
export class ApiError extends Error {}
|
||||||
|
|
||||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
headers: init.body ? { "content-type": "application/json" } : undefined,
|
headers: init.body ? { "content-type": "application/json" } : undefined,
|
||||||
...init,
|
...init,
|
||||||
});
|
});
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
const body = text ? JSON.parse(text) : null;
|
const body = text ? JSON.parse(text) : null;
|
||||||
if (!res.ok) throw new ApiError(body?.error ?? `Ошибка ${res.status}`);
|
if (!res.ok) throw new ApiError(body?.error ?? `Ошибка ${res.status}`);
|
||||||
return body as T;
|
return body as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
user?: { id: string; username: string };
|
user?: { id: string; username: string };
|
||||||
mfaRequired?: boolean;
|
mfaRequired?: boolean;
|
||||||
ticket?: string;
|
ticket?: string;
|
||||||
methods?: string[];
|
methods?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
me: () => request<Me>("/api/me"),
|
me: () => request<Me>("/api/me"),
|
||||||
|
|
||||||
login: (payload: {
|
login: (payload: {
|
||||||
email?: string;
|
email?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
mfaTicket?: string;
|
mfaTicket?: string;
|
||||||
totpCode?: string;
|
totpCode?: string;
|
||||||
recoveryCode?: string;
|
recoveryCode?: string;
|
||||||
}) => request<LoginResponse>("/api/auth/login", { method: "POST", body: JSON.stringify(payload) }),
|
}) => request<LoginResponse>("/api/auth/login", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
|
|
||||||
loginWithLink: (token: string) =>
|
loginWithLink: (token: string) =>
|
||||||
request<{ user: { id: string; username: string }; serverId: string | null }>("/api/auth/link", {
|
request<{ user: { id: string; username: string }; serverId: string | null }>("/api/auth/link", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ token }),
|
body: JSON.stringify({ token }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
logout: () => request<{ ok: true }>("/api/auth/logout", { method: "POST" }),
|
logout: () => request<{ ok: true }>("/api/auth/logout", { method: "POST" }),
|
||||||
|
|
||||||
state: (serverId: string) => request<ServerStateResponse>(`/api/servers/${serverId}/state`),
|
state: (serverId: string) => request<ServerStateResponse>(`/api/servers/${serverId}/state`),
|
||||||
|
|
||||||
search: (serverId: string, query: string) =>
|
search: (serverId: string, query: string) =>
|
||||||
request<{ tracks: Track[] }>(`/api/servers/${serverId}/search?q=${encodeURIComponent(query)}`),
|
request<{ tracks: Track[] }>(`/api/servers/${serverId}/search?q=${encodeURIComponent(query)}`),
|
||||||
|
|
||||||
play: (
|
play: (
|
||||||
serverId: string,
|
serverId: string,
|
||||||
payload: { query?: string; trackIds?: string[]; mode?: "append" | "next" | "now"; voiceChannelId?: string | null },
|
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) }),
|
) => request<{ ok: true }>(`/api/servers/${serverId}/play`, { method: "POST", body: JSON.stringify(payload) }),
|
||||||
|
|
||||||
action: (serverId: string, action: string, payload: Record<string, unknown> = {}) =>
|
action: (serverId: string, action: string, payload: Record<string, unknown> = {}) =>
|
||||||
request<{ ok: true }>(`/api/servers/${serverId}/actions/${action}`, {
|
request<{ ok: true }>(`/api/servers/${serverId}/actions/${action}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
join: (serverId: string, voiceChannelId: string | null) =>
|
join: (serverId: string, voiceChannelId: string | null) =>
|
||||||
request<{ ok: true }>(`/api/servers/${serverId}/join`, {
|
request<{ ok: true }>(`/api/servers/${serverId}/join`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ voiceChannelId }),
|
body: JSON.stringify({ voiceChannelId }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
removeTrack: (serverId: string, trackId: string) =>
|
removeTrack: (serverId: string, trackId: string) =>
|
||||||
request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}`, { method: "DELETE" }),
|
request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}`, { method: "DELETE" }),
|
||||||
|
|
||||||
moveTrack: (serverId: string, trackId: string, index: number) =>
|
moveTrack: (serverId: string, trackId: string, index: number) =>
|
||||||
request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}/move`, {
|
request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}/move`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ index }),
|
body: JSON.stringify({ index }),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatDuration(seconds: number): string {
|
/** Clock formatting for any position or length; 0 is a legitimate "0:00". */
|
||||||
if (!Number.isFinite(seconds) || seconds <= 0) return "LIVE";
|
export function formatDuration(seconds: number): string {
|
||||||
const total = Math.floor(seconds);
|
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
|
||||||
const hours = Math.floor(total / 3600);
|
const total = Math.floor(seconds);
|
||||||
const minutes = Math.floor((total % 3600) / 60);
|
const hours = Math.floor(total / 3600);
|
||||||
const secs = total % 60;
|
const minutes = Math.floor((total % 3600) / 60);
|
||||||
const pad = (value: number) => value.toString().padStart(2, "0");
|
const secs = total % 60;
|
||||||
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
+200
-177
@@ -1,177 +1,200 @@
|
|||||||
import { useEffect, useState, type MouseEvent } from "react";
|
import { useEffect, useState, type MouseEvent } from "react";
|
||||||
import { formatDuration } from "../api";
|
import { formatDuration, formatLength } from "../api";
|
||||||
import type { LoopMode, PlayerState, VoiceChannel } from "../types";
|
import type { LoopMode, PlayerState, VoiceChannel } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
state: PlayerState;
|
state: PlayerState;
|
||||||
position: number;
|
position: number;
|
||||||
canControl: boolean;
|
canControl: boolean;
|
||||||
voiceChannels: VoiceChannel[];
|
voiceChannels: VoiceChannel[];
|
||||||
yourVoiceChannel: VoiceChannel | null;
|
yourVoiceChannel: VoiceChannel | null;
|
||||||
onAction(action: string, payload?: Record<string, unknown>): void;
|
requireListener: boolean;
|
||||||
onSeek(seconds: number): void;
|
onAction(action: string, payload?: Record<string, unknown>): void;
|
||||||
onJoin(channelId: string | null): void;
|
onSeek(seconds: number): void;
|
||||||
}
|
onJoin(channelId: string | null): void;
|
||||||
|
}
|
||||||
const STATUS_LABEL: Record<PlayerState["status"], string> = {
|
|
||||||
idle: "ожидание",
|
const STATUS_LABEL: Record<PlayerState["status"], string> = {
|
||||||
connecting: "подключение",
|
idle: "ожидание",
|
||||||
buffering: "буферизация",
|
connecting: "подключение",
|
||||||
playing: "играет",
|
buffering: "буферизация",
|
||||||
paused: "пауза",
|
playing: "играет",
|
||||||
};
|
paused: "пауза",
|
||||||
|
};
|
||||||
const LOOP_LABEL: Record<LoopMode, string> = {
|
|
||||||
off: "🔁 выкл",
|
const LOOP_LABEL: Record<LoopMode, string> = {
|
||||||
track: "🔂 трек",
|
off: "🔁 выкл",
|
||||||
queue: "🔁 очередь",
|
track: "🔂 трек",
|
||||||
};
|
queue: "🔁 очередь",
|
||||||
|
};
|
||||||
export function NowPlaying({
|
|
||||||
state,
|
export function NowPlaying({
|
||||||
position,
|
state,
|
||||||
canControl,
|
position,
|
||||||
voiceChannels,
|
canControl,
|
||||||
yourVoiceChannel,
|
voiceChannels,
|
||||||
onAction,
|
yourVoiceChannel,
|
||||||
onSeek,
|
requireListener,
|
||||||
onJoin,
|
onAction,
|
||||||
}: Props) {
|
onSeek,
|
||||||
const [volume, setVolume] = useState(state.volume);
|
onJoin,
|
||||||
const [channelId, setChannelId] = useState<string>(
|
}: Props) {
|
||||||
state.voiceChannelId ?? yourVoiceChannel?.id ?? voiceChannels[0]?.id ?? "",
|
const [volume, setVolume] = useState(state.volume);
|
||||||
);
|
const [channelId, setChannelId] = useState<string>(
|
||||||
|
state.voiceChannelId ?? yourVoiceChannel?.id ?? voiceChannels[0]?.id ?? "",
|
||||||
useEffect(() => setVolume(state.volume), [state.volume]);
|
);
|
||||||
useEffect(() => {
|
|
||||||
if (state.voiceChannelId) setChannelId(state.voiceChannelId);
|
useEffect(() => setVolume(state.volume), [state.volume]);
|
||||||
}, [state.voiceChannelId]);
|
useEffect(() => {
|
||||||
|
if (state.voiceChannelId) setChannelId(state.voiceChannelId);
|
||||||
const track = state.current;
|
}, [state.voiceChannelId]);
|
||||||
const duration = track?.duration ?? 0;
|
|
||||||
const ratio = duration > 0 ? Math.min(1, position / duration) : 0;
|
const track = state.current;
|
||||||
const isPlaying = state.status === "playing" || state.status === "buffering";
|
const duration = track?.duration ?? 0;
|
||||||
|
const ratio = duration > 0 ? Math.min(1, position / duration) : 0;
|
||||||
function seekFromClick(event: MouseEvent<HTMLDivElement>) {
|
const isPlaying = state.status === "playing" || state.status === "buffering";
|
||||||
if (!track || duration <= 0 || !canControl) return;
|
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
function seekFromClick(event: MouseEvent<HTMLDivElement>) {
|
||||||
const fraction = (event.clientX - rect.left) / rect.width;
|
if (!track || duration <= 0 || !canControl) return;
|
||||||
onSeek(Math.max(0, Math.min(duration - 1, fraction * duration)));
|
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 (
|
const nextLoop: LoopMode = state.loop === "off" ? "track" : state.loop === "track" ? "queue" : "off";
|
||||||
<div className="card">
|
|
||||||
<h2>
|
return (
|
||||||
Сейчас играет{" "}
|
<div className="card">
|
||||||
<span className={`status-pill ${state.status === "playing" ? "playing" : ""}`}>
|
<h2>
|
||||||
{STATUS_LABEL[state.status]}
|
Сейчас играет{" "}
|
||||||
</span>
|
<span className={`status-pill ${state.status === "playing" ? "playing" : ""}`}>
|
||||||
</h2>
|
{STATUS_LABEL[state.status]}
|
||||||
|
</span>
|
||||||
<div className="now">
|
</h2>
|
||||||
{track?.thumbnail ? (
|
|
||||||
<img className="cover" src={track.thumbnail} alt="" />
|
<div className="now">
|
||||||
) : (
|
{track?.thumbnail ? (
|
||||||
<div className="cover placeholder">🎵</div>
|
<img className="cover" src={track.thumbnail} alt="" />
|
||||||
)}
|
) : (
|
||||||
<div className="now-meta">
|
<div className="cover placeholder">🎵</div>
|
||||||
<div className="now-title">
|
)}
|
||||||
{track ? (
|
<div className="now-meta">
|
||||||
/^https?:/.test(track.url) ? (
|
<div className="now-title">
|
||||||
<a href={track.url} target="_blank" rel="noreferrer noopener">
|
{track ? (
|
||||||
{track.title}
|
/^https?:/.test(track.url) ? (
|
||||||
</a>
|
<a href={track.url} target="_blank" rel="noreferrer noopener">
|
||||||
) : (
|
{track.title}
|
||||||
track.title
|
</a>
|
||||||
)
|
) : (
|
||||||
) : (
|
track.title
|
||||||
"Тишина"
|
)
|
||||||
)}
|
) : (
|
||||||
</div>
|
"Тишина"
|
||||||
<div className="now-sub">
|
)}
|
||||||
{track
|
</div>
|
||||||
? [track.author, `запросил ${track.requestedBy.username}`].filter(Boolean).join(" · ")
|
<div className="now-sub">
|
||||||
: "Очередь пуста — найдите что-нибудь слева"}
|
{track
|
||||||
</div>
|
? [track.author, `запросил ${track.requestedBy.username}`].filter(Boolean).join(" · ")
|
||||||
<div className="progress">
|
: "Очередь пуста — найдите что-нибудь слева"}
|
||||||
<div className="bar" onClick={seekFromClick}>
|
</div>
|
||||||
<span style={{ width: `${ratio * 100}%` }} />
|
<div className="progress">
|
||||||
</div>
|
<div className="bar" onClick={seekFromClick}>
|
||||||
<div className="times">
|
<span style={{ width: `${ratio * 100}%` }} />
|
||||||
<span>{track ? formatDuration(position) : "0:00"}</span>
|
</div>
|
||||||
<span>{track?.isLive ? "LIVE" : formatDuration(duration)}</span>
|
<div className="times">
|
||||||
</div>
|
<span>{formatDuration(track ? position : 0)}</span>
|
||||||
</div>
|
<span>{track ? formatLength(track) : "0:00"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="controls">
|
</div>
|
||||||
<button
|
|
||||||
className="icon big primary"
|
<div className="controls">
|
||||||
onClick={() => onAction("toggle")}
|
<button
|
||||||
disabled={!canControl || !track}
|
className="icon big primary"
|
||||||
title={isPlaying ? "Пауза" : "Играть"}
|
onClick={() => onAction("toggle")}
|
||||||
>
|
disabled={!canControl || !track}
|
||||||
{isPlaying ? "⏸" : "▶"}
|
title={isPlaying ? "Пауза" : "Играть"}
|
||||||
</button>
|
>
|
||||||
<button className="icon" onClick={() => onAction("skip")} disabled={!canControl} title="Следующий">
|
{isPlaying ? "⏸" : "▶"}
|
||||||
⏭
|
</button>
|
||||||
</button>
|
<button className="icon" onClick={() => onAction("skip")} disabled={!canControl} title="Следующий">
|
||||||
<button className="icon" onClick={() => onAction("stop")} disabled={!canControl} title="Стоп">
|
⏭
|
||||||
⏹
|
</button>
|
||||||
</button>
|
<button className="icon" onClick={() => onAction("stop")} disabled={!canControl} title="Стоп">
|
||||||
<button
|
⏹
|
||||||
className="icon"
|
</button>
|
||||||
onClick={() => onAction("shuffle")}
|
<button
|
||||||
disabled={!canControl || state.queue.length < 2}
|
className="icon"
|
||||||
title="Перемешать"
|
onClick={() => onAction("shuffle")}
|
||||||
>
|
disabled={!canControl || state.queue.length < 2}
|
||||||
🔀
|
title="Перемешать"
|
||||||
</button>
|
>
|
||||||
<button
|
🔀
|
||||||
className={state.loop === "off" ? "" : "active"}
|
</button>
|
||||||
onClick={() => onAction("loop", { mode: nextLoop })}
|
<button
|
||||||
disabled={!canControl}
|
className={state.loop === "off" ? "" : "active"}
|
||||||
title="Режим повтора"
|
onClick={() => onAction("loop", { mode: nextLoop })}
|
||||||
>
|
disabled={!canControl}
|
||||||
{LOOP_LABEL[state.loop]}
|
title="Режим повтора"
|
||||||
</button>
|
>
|
||||||
|
{LOOP_LABEL[state.loop]}
|
||||||
<div className="volume">
|
</button>
|
||||||
<span title="Громкость">🔊</span>
|
|
||||||
<input
|
<div className="volume">
|
||||||
type="range"
|
<span title="Громкость">🔊</span>
|
||||||
min={0}
|
<input
|
||||||
max={200}
|
type="range"
|
||||||
value={volume}
|
min={0}
|
||||||
disabled={!canControl}
|
max={200}
|
||||||
onChange={(event) => setVolume(Number(event.target.value))}
|
value={volume}
|
||||||
onMouseUp={() => onAction("volume", { volume })}
|
disabled={!canControl}
|
||||||
onTouchEnd={() => onAction("volume", { volume })}
|
onChange={(event) => setVolume(Number(event.target.value))}
|
||||||
/>
|
onMouseUp={() => onAction("volume", { volume })}
|
||||||
<span className="badge">{volume}%</span>
|
onTouchEnd={() => onAction("volume", { volume })}
|
||||||
</div>
|
/>
|
||||||
</div>
|
<span className="badge">{volume}%</span>
|
||||||
|
</div>
|
||||||
<div className="voice-row">
|
</div>
|
||||||
<select value={channelId} onChange={(event) => setChannelId(event.target.value)} disabled={!canControl}>
|
|
||||||
{voiceChannels.length === 0 && <option value="">Нет голосовых каналов</option>}
|
<div className="voice-row">
|
||||||
{voiceChannels.map((channel) => (
|
{requireListener ? (
|
||||||
<option key={channel.id} value={channel.id}>
|
// Playback follows the listener, so there is nothing to choose here:
|
||||||
{channel.name}
|
// the bot joins the channel you are sitting in.
|
||||||
{yourVoiceChannel?.id === channel.id ? " (вы здесь)" : ""}
|
<span className="voice-status">
|
||||||
</option>
|
{yourVoiceChannel ? (
|
||||||
))}
|
<>
|
||||||
</select>
|
Вы в канале <strong>{yourVoiceChannel.name}</strong>
|
||||||
<button onClick={() => onJoin(channelId || null)} disabled={!canControl || !channelId}>
|
</>
|
||||||
{state.voiceChannelId === channelId ? "Переподключить" : "Зайти"}
|
) : (
|
||||||
</button>
|
"Вы не в голосовом канале"
|
||||||
<button onClick={() => onAction("leave")} disabled={!canControl || !state.voiceChannelId}>
|
)}
|
||||||
Выйти
|
{state.voiceChannelName ? ` · бот в «${state.voiceChannelName}»` : " · бот не в канале"}
|
||||||
</button>
|
</span>
|
||||||
</div>
|
) : (
|
||||||
</div>
|
<select value={channelId} onChange={(event) => setChannelId(event.target.value)} disabled={!canControl}>
|
||||||
);
|
{voiceChannels.length === 0 && <option value="">Нет голосовых каналов</option>}
|
||||||
}
|
{voiceChannels.map((channel) => (
|
||||||
|
<option key={channel.id} value={channel.id}>
|
||||||
|
{channel.name}
|
||||||
|
{yourVoiceChannel?.id === channel.id ? " (вы здесь)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onJoin(requireListener ? null : channelId || null)}
|
||||||
|
disabled={!canControl || (requireListener ? !yourVoiceChannel : !channelId)}
|
||||||
|
title={requireListener && !yourVoiceChannel ? "Сначала зайдите в голосовой канал" : undefined}
|
||||||
|
>
|
||||||
|
{state.voiceChannelId && state.voiceChannelId === (requireListener ? yourVoiceChannel?.id : channelId)
|
||||||
|
? "Переподключить"
|
||||||
|
: "Позвать"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onAction("leave")} disabled={!canControl || !state.voiceChannelId}>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,77 +1,75 @@
|
|||||||
import { formatDuration } from "../api";
|
import { formatDuration, formatLength } from "../api";
|
||||||
import type { Track } from "../types";
|
import type { Track } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tracks: Track[];
|
tracks: Track[];
|
||||||
canControl: boolean;
|
canControl: boolean;
|
||||||
onRemove(track: Track): void;
|
onRemove(track: Track): void;
|
||||||
onMove(track: Track, index: number): void;
|
onMove(track: Track, index: number): void;
|
||||||
onClear(): void;
|
onClear(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QueueList({ tracks, canControl, onRemove, onMove, onClear }: Props) {
|
export function QueueList({ tracks, canControl, onRemove, onMove, onClear }: Props) {
|
||||||
const total = tracks.reduce((acc, track) => acc + track.duration, 0);
|
const total = tracks.reduce((acc, track) => acc + track.duration, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>
|
<h2>
|
||||||
Очередь · {tracks.length}
|
Очередь · {tracks.length}
|
||||||
{total > 0 ? ` · ${formatDuration(total)}` : ""}
|
{total > 0 ? ` · ${formatDuration(total)}` : ""}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{tracks.length === 0 ? (
|
{tracks.length === 0 ? (
|
||||||
<div className="empty">Очередь пуста</div>
|
<div className="empty">Очередь пуста</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ul className="track-list">
|
<ul className="track-list">
|
||||||
{tracks.map((track, index) => (
|
{tracks.map((track, index) => (
|
||||||
<li className="track" key={track.id}>
|
<li className="track" key={track.id}>
|
||||||
<span className="idx">{index + 1}</span>
|
<span className="idx">{index + 1}</span>
|
||||||
{track.thumbnail ? <img className="thumb" src={track.thumbnail} alt="" /> : <div className="thumb" />}
|
{track.thumbnail ? <img className="thumb" src={track.thumbnail} alt="" /> : <div className="thumb" />}
|
||||||
<div className="info">
|
<div className="info">
|
||||||
<div className="title">{track.title}</div>
|
<div className="title">{track.title}</div>
|
||||||
<div className="sub">
|
<div className="sub">
|
||||||
{[track.author, track.isLive ? "LIVE" : formatDuration(track.duration), track.requestedBy.username]
|
{[track.author, formatLength(track), track.requestedBy.username].filter(Boolean).join(" · ")}
|
||||||
.filter(Boolean)
|
</div>
|
||||||
.join(" · ")}
|
</div>
|
||||||
</div>
|
<div className="actions">
|
||||||
</div>
|
<button
|
||||||
<div className="actions">
|
onClick={() => onMove(track, 0)}
|
||||||
<button
|
disabled={!canControl || index === 0}
|
||||||
onClick={() => onMove(track, 0)}
|
title="Наверх очереди"
|
||||||
disabled={!canControl || index === 0}
|
>
|
||||||
title="Наверх очереди"
|
⤒
|
||||||
>
|
</button>
|
||||||
⤒
|
<button
|
||||||
</button>
|
onClick={() => onMove(track, Math.max(0, index - 1))}
|
||||||
<button
|
disabled={!canControl || index === 0}
|
||||||
onClick={() => onMove(track, Math.max(0, index - 1))}
|
title="Выше"
|
||||||
disabled={!canControl || index === 0}
|
>
|
||||||
title="Выше"
|
↑
|
||||||
>
|
</button>
|
||||||
↑
|
<button
|
||||||
</button>
|
onClick={() => onMove(track, index + 1)}
|
||||||
<button
|
disabled={!canControl || index === tracks.length - 1}
|
||||||
onClick={() => onMove(track, index + 1)}
|
title="Ниже"
|
||||||
disabled={!canControl || index === tracks.length - 1}
|
>
|
||||||
title="Ниже"
|
↓
|
||||||
>
|
</button>
|
||||||
↓
|
<button onClick={() => onRemove(track)} disabled={!canControl} title="Убрать">
|
||||||
</button>
|
✕
|
||||||
<button onClick={() => onRemove(track)} disabled={!canControl} title="Убрать">
|
</button>
|
||||||
✕
|
</div>
|
||||||
</button>
|
</li>
|
||||||
</div>
|
))}
|
||||||
</li>
|
</ul>
|
||||||
))}
|
<div className="row" style={{ marginTop: 12 }}>
|
||||||
</ul>
|
<button className="ghost" onClick={onClear} disabled={!canControl}>
|
||||||
<div className="row" style={{ marginTop: 12 }}>
|
Очистить очередь
|
||||||
<button className="ghost" onClick={onClear} disabled={!canControl}>
|
</button>
|
||||||
Очистить очередь
|
</div>
|
||||||
</button>
|
</>
|
||||||
</div>
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
);
|
||||||
</div>
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
+124
-117
@@ -1,117 +1,124 @@
|
|||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { api, formatDuration } from "../api";
|
import { api, formatLength } from "../api";
|
||||||
import type { Track } from "../types";
|
import type { Track } from "../types";
|
||||||
|
|
||||||
const SOURCE_BADGE: Record<Track["source"], string> = {
|
const SOURCE_BADGE: Record<Track["source"], string> = {
|
||||||
youtube: "YouTube",
|
youtube: "YouTube",
|
||||||
soundcloud: "SoundCloud",
|
soundcloud: "SoundCloud",
|
||||||
direct: "Ссылка",
|
direct: "Ссылка",
|
||||||
local: "Медиатека",
|
local: "Медиатека",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
canControl: boolean;
|
canControl: boolean;
|
||||||
localLibrary: boolean;
|
localLibrary: boolean;
|
||||||
onError(message: string | null): void;
|
onError(message: string | null): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchPanel({ serverId, canControl, localLibrary, onError }: Props) {
|
export function SearchPanel({ serverId, canControl, localLibrary, onError }: Props) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<Track[]>([]);
|
const [results, setResults] = useState<Track[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [lastAdded, setLastAdded] = useState<string | null>(null);
|
const [lastAdded, setLastAdded] = useState<string | null>(null);
|
||||||
|
const [searched, setSearched] = useState(false);
|
||||||
async function submit(event: FormEvent) {
|
|
||||||
event.preventDefault();
|
async function submit(event: FormEvent) {
|
||||||
const value = query.trim();
|
event.preventDefault();
|
||||||
if (!value) return;
|
const value = query.trim();
|
||||||
setBusy(true);
|
if (!value) return;
|
||||||
onError(null);
|
setBusy(true);
|
||||||
try {
|
onError(null);
|
||||||
if (/^https?:\/\//i.test(value)) {
|
try {
|
||||||
await api.play(serverId, { query: value });
|
if (/^https?:\/\//i.test(value)) {
|
||||||
setLastAdded(value);
|
await api.play(serverId, { query: value });
|
||||||
setResults([]);
|
setLastAdded(value);
|
||||||
setQuery("");
|
setResults([]);
|
||||||
return;
|
setSearched(false);
|
||||||
}
|
setQuery("");
|
||||||
const { tracks } = await api.search(serverId, value);
|
return;
|
||||||
setResults(tracks);
|
}
|
||||||
} catch (err) {
|
const { tracks } = await api.search(serverId, value);
|
||||||
onError(err instanceof Error ? err.message : "Поиск не удался");
|
setResults(tracks);
|
||||||
} finally {
|
setSearched(true);
|
||||||
setBusy(false);
|
} 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 });
|
async function enqueue(track: Track, mode: "append" | "next" | "now") {
|
||||||
setLastAdded(track.title);
|
onError(null);
|
||||||
} catch (err) {
|
try {
|
||||||
onError(err instanceof Error ? err.message : "Не удалось добавить трек");
|
await api.play(serverId, { trackIds: [track.id], mode });
|
||||||
}
|
setLastAdded(track.title);
|
||||||
}
|
} catch (err) {
|
||||||
|
onError(err instanceof Error ? err.message : "Не удалось добавить трек");
|
||||||
return (
|
}
|
||||||
<div className="card">
|
}
|
||||||
<h2>Поиск</h2>
|
|
||||||
<form className="search-form" onSubmit={submit}>
|
return (
|
||||||
<input
|
<div className="card grow">
|
||||||
value={query}
|
<h2>Поиск</h2>
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
<form className="search-form" onSubmit={submit}>
|
||||||
placeholder="Название трека или ссылка…"
|
<input
|
||||||
disabled={!canControl}
|
value={query}
|
||||||
/>
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
<button className="primary" type="submit" disabled={busy || !canControl}>
|
placeholder="Название трека или ссылка…"
|
||||||
{busy ? "…" : "Найти"}
|
disabled={!canControl}
|
||||||
</button>
|
/>
|
||||||
</form>
|
<button className="primary" type="submit" disabled={busy || !canControl}>
|
||||||
|
{busy ? "…" : "Найти"}
|
||||||
{results.length === 0 ? (
|
</button>
|
||||||
<div className="empty">
|
</form>
|
||||||
{lastAdded ? `Добавлено: ${lastAdded}` : "Введите запрос или вставьте ссылку — плейлисты тоже поддерживаются."}
|
|
||||||
</div>
|
{results.length === 0 ? (
|
||||||
) : (
|
<div className="empty">
|
||||||
<ul className="track-list">
|
{searched
|
||||||
{results.map((track, index) => (
|
? `По запросу «${query}» ничего не нашлось`
|
||||||
<li className="track" key={track.id}>
|
: lastAdded
|
||||||
<span className="idx">{index + 1}</span>
|
? `Добавлено: ${lastAdded}`
|
||||||
{track.thumbnail ? <img className="thumb" src={track.thumbnail} alt="" /> : <div className="thumb" />}
|
: "Введите запрос или вставьте ссылку — плейлисты тоже поддерживаются."}
|
||||||
<div className="info">
|
</div>
|
||||||
<div className="title">{track.title}</div>
|
) : (
|
||||||
<div className="sub">
|
<ul className="track-list">
|
||||||
{[track.author, track.isLive ? "LIVE" : formatDuration(track.duration)].filter(Boolean).join(" · ")}
|
{results.map((track, index) => (
|
||||||
</div>
|
<li className="track" key={track.id}>
|
||||||
</div>
|
<span className="idx">{index + 1}</span>
|
||||||
<span className="badge">{SOURCE_BADGE[track.source]}</span>
|
{track.thumbnail ? <img className="thumb" src={track.thumbnail} alt="" /> : <div className="thumb" />}
|
||||||
<div className="actions">
|
<div className="info">
|
||||||
<button onClick={() => enqueue(track, "now")} disabled={!canControl} title="Играть сейчас">
|
<div className="title">{track.title}</div>
|
||||||
▶
|
<div className="sub">
|
||||||
</button>
|
{[track.author, formatLength(track)].filter(Boolean).join(" · ")}
|
||||||
<button onClick={() => enqueue(track, "next")} disabled={!canControl} title="Следующим">
|
</div>
|
||||||
⤴
|
</div>
|
||||||
</button>
|
<span className="badge">{SOURCE_BADGE[track.source]}</span>
|
||||||
<button onClick={() => enqueue(track, "append")} disabled={!canControl} title="В очередь">
|
<div className="actions">
|
||||||
+
|
<button onClick={() => enqueue(track, "now")} disabled={!canControl} title="Играть сейчас">
|
||||||
</button>
|
▶
|
||||||
</div>
|
</button>
|
||||||
</li>
|
<button onClick={() => enqueue(track, "next")} disabled={!canControl} title="Следующим">
|
||||||
))}
|
⤴
|
||||||
</ul>
|
</button>
|
||||||
)}
|
<button onClick={() => enqueue(track, "append")} disabled={!canControl} title="В очередь">
|
||||||
|
+
|
||||||
<p className="hint">
|
</button>
|
||||||
Префиксы: <code>sc:</code> — искать в SoundCloud, <code>yt:</code> — в YouTube
|
</div>
|
||||||
{localLibrary ? (
|
</li>
|
||||||
<>
|
))}
|
||||||
, <code>local:</code> — в локальной медиатеке
|
</ul>
|
||||||
</>
|
)}
|
||||||
) : null}
|
|
||||||
.
|
<p className="hint">
|
||||||
</p>
|
Префиксы: <code>sc:</code> — искать в SoundCloud, <code>yt:</code> — в YouTube
|
||||||
</div>
|
{localLibrary ? (
|
||||||
);
|
<>
|
||||||
}
|
, <code>local:</code> — в локальной медиатеке
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+511
-475
@@ -1,475 +1,511 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg: #0f1014;
|
--bg: #0f1014;
|
||||||
--bg-elev: #171922;
|
--bg-elev: #171922;
|
||||||
--bg-elev-2: #1e2130;
|
--bg-elev-2: #1e2130;
|
||||||
--line: #2a2e3f;
|
--line: #2a2e3f;
|
||||||
--text: #e8eaf2;
|
--text: #e8eaf2;
|
||||||
--muted: #9aa0b5;
|
--muted: #9aa0b5;
|
||||||
--accent: #7b6cf6;
|
--accent: #7b6cf6;
|
||||||
--accent-soft: rgba(123, 108, 246, 0.16);
|
--accent-soft: rgba(123, 108, 246, 0.16);
|
||||||
--danger: #f2555a;
|
--danger: #f2555a;
|
||||||
--ok: #3ecf8e;
|
--ok: #3ecf8e;
|
||||||
--radius: 14px;
|
--radius: 14px;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: radial-gradient(1200px 600px at 20% -10%, #1b1e2e 0%, var(--bg) 60%);
|
background: radial-gradient(1200px 600px at 20% -10%, #1b1e2e 0%, var(--bg) 60%);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font: 15px/1.5 "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
font: 15px/1.5 "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: var(--bg-elev-2);
|
background: var(--bg-elev-2);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s ease, border-color 0.15s ease, transform 0.05s ease;
|
transition: background 0.15s ease, border-color 0.15s ease, transform 0.05s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover:not(:disabled) {
|
button:hover:not(:disabled) {
|
||||||
background: #262a3c;
|
background: #262a3c;
|
||||||
border-color: #3a3f56;
|
border-color: #3a3f56;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:active:not(:disabled) {
|
button:active:not(:disabled) {
|
||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.primary {
|
button.primary {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.primary:hover:not(:disabled) {
|
button.primary:hover:not(:disabled) {
|
||||||
background: #8b7dff;
|
background: #8b7dff;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.ghost {
|
button.ghost {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.icon {
|
button.icon {
|
||||||
width: 42px;
|
width: 42px;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.icon.big {
|
button.icon.big {
|
||||||
width: 54px;
|
width: 54px;
|
||||||
height: 54px;
|
height: 54px;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.active {
|
button.active {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
color: #cfc7ff;
|
color: #cfc7ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
select {
|
select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
background: var(--bg-elev-2);
|
background: var(--bg-elev-2);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus,
|
input:focus,
|
||||||
select:focus {
|
select:focus {
|
||||||
outline: 2px solid var(--accent-soft);
|
outline: 2px solid var(--accent-soft);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
max-width: 1180px;
|
max-width: 1180px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 20px 18px 60px;
|
padding: 20px 18px 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 12px 4px 20px;
|
padding: 12px 4px 20px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand .dot {
|
.brand .dot {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
box-shadow: 0 0 14px var(--accent);
|
box-shadow: 0 0 14px var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.spacer {
|
.spacer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar select {
|
.topbar select {
|
||||||
width: auto;
|
width: auto;
|
||||||
min-width: 190px;
|
min-width: 190px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.who {
|
.who {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
align-items: start;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
.layout > div {
|
||||||
.layout {
|
display: flex;
|
||||||
grid-template-columns: 1fr;
|
flex-direction: column;
|
||||||
}
|
gap: 18px;
|
||||||
}
|
min-width: 0;
|
||||||
|
}
|
||||||
.card {
|
|
||||||
background: var(--bg-elev);
|
@media (max-width: 900px) {
|
||||||
border: 1px solid var(--line);
|
.layout {
|
||||||
border-radius: var(--radius);
|
grid-template-columns: 1fr;
|
||||||
padding: 18px;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card + .card {
|
.card {
|
||||||
margin-top: 18px;
|
background: var(--bg-elev);
|
||||||
}
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
.card h2 {
|
padding: 18px;
|
||||||
margin: 0 0 14px;
|
}
|
||||||
font-size: 14px;
|
|
||||||
text-transform: uppercase;
|
/* The search column fills the available height so results have room to breathe. */
|
||||||
letter-spacing: 0.08em;
|
.card.grow {
|
||||||
color: var(--muted);
|
display: flex;
|
||||||
font-weight: 600;
|
flex-direction: column;
|
||||||
}
|
min-height: 460px;
|
||||||
|
}
|
||||||
.row {
|
|
||||||
display: flex;
|
.card.grow .track-list {
|
||||||
gap: 10px;
|
flex: 1;
|
||||||
align-items: center;
|
max-height: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.now {
|
.card.grow .empty {
|
||||||
display: flex;
|
flex: 1;
|
||||||
gap: 16px;
|
display: grid;
|
||||||
align-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover {
|
.card h2 {
|
||||||
width: 96px;
|
margin: 0 0 14px;
|
||||||
height: 96px;
|
font-size: 14px;
|
||||||
border-radius: 12px;
|
text-transform: uppercase;
|
||||||
object-fit: cover;
|
letter-spacing: 0.08em;
|
||||||
background: var(--bg-elev-2);
|
color: var(--muted);
|
||||||
flex-shrink: 0;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover.placeholder {
|
.row {
|
||||||
display: grid;
|
display: flex;
|
||||||
place-items: center;
|
gap: 10px;
|
||||||
font-size: 30px;
|
align-items: center;
|
||||||
color: var(--muted);
|
}
|
||||||
}
|
|
||||||
|
.now {
|
||||||
.now-meta {
|
display: flex;
|
||||||
min-width: 0;
|
gap: 16px;
|
||||||
flex: 1;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.now-title {
|
.cover {
|
||||||
font-size: 19px;
|
width: 96px;
|
||||||
font-weight: 650;
|
height: 96px;
|
||||||
line-height: 1.25;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
object-fit: cover;
|
||||||
text-overflow: ellipsis;
|
background: var(--bg-elev-2);
|
||||||
white-space: nowrap;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.now-title a {
|
.cover.placeholder {
|
||||||
color: inherit;
|
display: grid;
|
||||||
text-decoration: none;
|
place-items: center;
|
||||||
}
|
font-size: 30px;
|
||||||
|
color: var(--muted);
|
||||||
.now-title a:hover {
|
}
|
||||||
text-decoration: underline;
|
|
||||||
}
|
.now-meta {
|
||||||
|
min-width: 0;
|
||||||
.now-sub {
|
flex: 1;
|
||||||
color: var(--muted);
|
}
|
||||||
font-size: 14px;
|
|
||||||
margin-top: 3px;
|
.now-title {
|
||||||
overflow: hidden;
|
font-size: 19px;
|
||||||
text-overflow: ellipsis;
|
font-weight: 650;
|
||||||
white-space: nowrap;
|
line-height: 1.25;
|
||||||
}
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
.progress {
|
white-space: nowrap;
|
||||||
margin-top: 16px;
|
}
|
||||||
}
|
|
||||||
|
.now-title a {
|
||||||
.bar {
|
color: inherit;
|
||||||
height: 8px;
|
text-decoration: none;
|
||||||
border-radius: 99px;
|
}
|
||||||
background: var(--bg-elev-2);
|
|
||||||
cursor: pointer;
|
.now-title a:hover {
|
||||||
position: relative;
|
text-decoration: underline;
|
||||||
overflow: hidden;
|
}
|
||||||
}
|
|
||||||
|
.now-sub {
|
||||||
.bar > span {
|
color: var(--muted);
|
||||||
position: absolute;
|
font-size: 14px;
|
||||||
inset: 0 auto 0 0;
|
margin-top: 3px;
|
||||||
background: linear-gradient(90deg, var(--accent), #a78bfa);
|
overflow: hidden;
|
||||||
border-radius: 99px;
|
text-overflow: ellipsis;
|
||||||
}
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.times {
|
|
||||||
display: flex;
|
.progress {
|
||||||
justify-content: space-between;
|
margin-top: 16px;
|
||||||
color: var(--muted);
|
}
|
||||||
font-size: 12px;
|
|
||||||
margin-top: 6px;
|
.bar {
|
||||||
font-variant-numeric: tabular-nums;
|
height: 8px;
|
||||||
}
|
border-radius: 99px;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
.controls {
|
cursor: pointer;
|
||||||
display: flex;
|
position: relative;
|
||||||
align-items: center;
|
overflow: hidden;
|
||||||
gap: 8px;
|
}
|
||||||
margin-top: 16px;
|
|
||||||
flex-wrap: wrap;
|
.bar > span {
|
||||||
}
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
.volume {
|
background: linear-gradient(90deg, var(--accent), #a78bfa);
|
||||||
display: flex;
|
border-radius: 99px;
|
||||||
align-items: center;
|
}
|
||||||
gap: 8px;
|
|
||||||
margin-left: auto;
|
.times {
|
||||||
min-width: 170px;
|
display: flex;
|
||||||
}
|
justify-content: space-between;
|
||||||
|
color: var(--muted);
|
||||||
.volume input[type="range"] {
|
font-size: 12px;
|
||||||
width: 110px;
|
margin-top: 6px;
|
||||||
padding: 0;
|
font-variant-numeric: tabular-nums;
|
||||||
accent-color: var(--accent);
|
}
|
||||||
background: transparent;
|
|
||||||
border: none;
|
.controls {
|
||||||
}
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
.status-pill {
|
gap: 8px;
|
||||||
font-size: 12px;
|
margin-top: 16px;
|
||||||
padding: 4px 9px;
|
flex-wrap: wrap;
|
||||||
border-radius: 99px;
|
}
|
||||||
background: var(--bg-elev-2);
|
|
||||||
color: var(--muted);
|
.volume {
|
||||||
border: 1px solid var(--line);
|
display: flex;
|
||||||
}
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
.status-pill.playing {
|
margin-left: auto;
|
||||||
color: var(--ok);
|
min-width: 170px;
|
||||||
border-color: rgba(62, 207, 142, 0.4);
|
}
|
||||||
}
|
|
||||||
|
.volume input[type="range"] {
|
||||||
.track-list {
|
width: 110px;
|
||||||
list-style: none;
|
padding: 0;
|
||||||
margin: 0;
|
accent-color: var(--accent);
|
||||||
padding: 0;
|
background: transparent;
|
||||||
display: flex;
|
border: none;
|
||||||
flex-direction: column;
|
}
|
||||||
gap: 6px;
|
|
||||||
max-height: 60vh;
|
.status-pill {
|
||||||
overflow-y: auto;
|
font-size: 12px;
|
||||||
}
|
padding: 4px 9px;
|
||||||
|
border-radius: 99px;
|
||||||
.track {
|
background: var(--bg-elev-2);
|
||||||
display: flex;
|
color: var(--muted);
|
||||||
align-items: center;
|
border: 1px solid var(--line);
|
||||||
gap: 11px;
|
}
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 10px;
|
.status-pill.playing {
|
||||||
border: 1px solid transparent;
|
color: var(--ok);
|
||||||
}
|
border-color: rgba(62, 207, 142, 0.4);
|
||||||
|
}
|
||||||
.track:hover {
|
|
||||||
background: var(--bg-elev-2);
|
.track-list {
|
||||||
border-color: var(--line);
|
list-style: none;
|
||||||
}
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
.track .idx {
|
display: flex;
|
||||||
width: 22px;
|
flex-direction: column;
|
||||||
text-align: right;
|
gap: 6px;
|
||||||
color: var(--muted);
|
max-height: 60vh;
|
||||||
font-size: 13px;
|
overflow-y: auto;
|
||||||
font-variant-numeric: tabular-nums;
|
}
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
.track {
|
||||||
|
display: flex;
|
||||||
.track .thumb {
|
align-items: center;
|
||||||
width: 44px;
|
gap: 11px;
|
||||||
height: 44px;
|
padding: 8px 10px;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
object-fit: cover;
|
border: 1px solid transparent;
|
||||||
background: var(--bg-elev-2);
|
}
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
.track:hover {
|
||||||
|
background: var(--bg-elev-2);
|
||||||
.track .info {
|
border-color: var(--line);
|
||||||
min-width: 0;
|
}
|
||||||
flex: 1;
|
|
||||||
}
|
.track .idx {
|
||||||
|
width: 22px;
|
||||||
.track .title {
|
text-align: right;
|
||||||
overflow: hidden;
|
color: var(--muted);
|
||||||
text-overflow: ellipsis;
|
font-size: 13px;
|
||||||
white-space: nowrap;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.track .sub {
|
|
||||||
color: var(--muted);
|
.track .thumb {
|
||||||
font-size: 12.5px;
|
width: 44px;
|
||||||
overflow: hidden;
|
height: 44px;
|
||||||
text-overflow: ellipsis;
|
border-radius: 8px;
|
||||||
white-space: nowrap;
|
object-fit: cover;
|
||||||
}
|
background: var(--bg-elev-2);
|
||||||
|
flex-shrink: 0;
|
||||||
.track .actions {
|
}
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
.track .info {
|
||||||
opacity: 0;
|
min-width: 0;
|
||||||
transition: opacity 0.15s ease;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track:hover .actions {
|
.track .title {
|
||||||
opacity: 1;
|
overflow: hidden;
|
||||||
}
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
.track .actions button {
|
}
|
||||||
padding: 5px 9px;
|
|
||||||
font-size: 13px;
|
.track .sub {
|
||||||
}
|
color: var(--muted);
|
||||||
|
font-size: 12.5px;
|
||||||
.badge {
|
overflow: hidden;
|
||||||
font-size: 11px;
|
text-overflow: ellipsis;
|
||||||
padding: 2px 7px;
|
white-space: nowrap;
|
||||||
border-radius: 6px;
|
}
|
||||||
background: var(--bg-elev-2);
|
|
||||||
border: 1px solid var(--line);
|
.track .actions {
|
||||||
color: var(--muted);
|
display: flex;
|
||||||
flex-shrink: 0;
|
gap: 6px;
|
||||||
}
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
.empty {
|
}
|
||||||
color: var(--muted);
|
|
||||||
text-align: center;
|
.track:hover .actions {
|
||||||
padding: 26px 10px;
|
opacity: 1;
|
||||||
font-size: 14px;
|
}
|
||||||
}
|
|
||||||
|
.track .actions button {
|
||||||
.search-form {
|
padding: 5px 9px;
|
||||||
display: flex;
|
font-size: 13px;
|
||||||
gap: 8px;
|
}
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
.badge {
|
||||||
|
font-size: 11px;
|
||||||
.hint {
|
padding: 2px 7px;
|
||||||
color: var(--muted);
|
border-radius: 6px;
|
||||||
font-size: 12.5px;
|
background: var(--bg-elev-2);
|
||||||
margin-top: 10px;
|
border: 1px solid var(--line);
|
||||||
}
|
color: var(--muted);
|
||||||
|
flex-shrink: 0;
|
||||||
.error {
|
}
|
||||||
background: rgba(242, 85, 90, 0.12);
|
|
||||||
border: 1px solid rgba(242, 85, 90, 0.4);
|
.empty {
|
||||||
color: #ffb4b6;
|
color: var(--muted);
|
||||||
padding: 10px 12px;
|
text-align: center;
|
||||||
border-radius: 10px;
|
padding: 26px 10px;
|
||||||
margin-bottom: 12px;
|
font-size: 14px;
|
||||||
font-size: 14px;
|
}
|
||||||
}
|
|
||||||
|
.search-form {
|
||||||
.login-wrap {
|
display: flex;
|
||||||
min-height: 100vh;
|
gap: 8px;
|
||||||
display: grid;
|
margin-bottom: 12px;
|
||||||
place-items: center;
|
}
|
||||||
padding: 20px;
|
|
||||||
}
|
.hint {
|
||||||
|
color: var(--muted);
|
||||||
.login {
|
font-size: 12.5px;
|
||||||
width: 100%;
|
margin-top: 10px;
|
||||||
max-width: 380px;
|
}
|
||||||
}
|
|
||||||
|
.error {
|
||||||
.login h1 {
|
background: rgba(242, 85, 90, 0.12);
|
||||||
font-size: 22px;
|
border: 1px solid rgba(242, 85, 90, 0.4);
|
||||||
margin: 0 0 4px;
|
color: #ffb4b6;
|
||||||
}
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
.login p.sub {
|
margin-bottom: 12px;
|
||||||
color: var(--muted);
|
font-size: 14px;
|
||||||
margin: 0 0 20px;
|
}
|
||||||
font-size: 14px;
|
|
||||||
}
|
.login-wrap {
|
||||||
|
min-height: 100vh;
|
||||||
.login label {
|
display: grid;
|
||||||
display: block;
|
place-items: center;
|
||||||
font-size: 13px;
|
padding: 20px;
|
||||||
color: var(--muted);
|
}
|
||||||
margin: 12px 0 6px;
|
|
||||||
}
|
.login {
|
||||||
|
width: 100%;
|
||||||
.login button {
|
max-width: 380px;
|
||||||
width: 100%;
|
}
|
||||||
margin-top: 18px;
|
|
||||||
}
|
.login h1 {
|
||||||
|
font-size: 22px;
|
||||||
.voice-row {
|
margin: 0 0 4px;
|
||||||
display: flex;
|
}
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
.login p.sub {
|
||||||
margin-top: 14px;
|
color: var(--muted);
|
||||||
padding-top: 14px;
|
margin: 0 0 20px;
|
||||||
border-top: 1px solid var(--line);
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-row select {
|
.login label {
|
||||||
flex: 1;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+54
-54
@@ -1,54 +1,54 @@
|
|||||||
export type SourceKind = "youtube" | "soundcloud" | "direct" | "local";
|
export type SourceKind = "youtube" | "soundcloud" | "direct" | "local";
|
||||||
export type LoopMode = "off" | "track" | "queue";
|
export type LoopMode = "off" | "track" | "queue";
|
||||||
export type PlayerStatus = "idle" | "connecting" | "buffering" | "playing" | "paused";
|
export type PlayerStatus = "idle" | "connecting" | "buffering" | "playing" | "paused";
|
||||||
|
|
||||||
export interface Track {
|
export interface Track {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
author: string | null;
|
author: string | null;
|
||||||
duration: number;
|
duration: number;
|
||||||
isLive: boolean;
|
isLive: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
thumbnail: string | null;
|
thumbnail: string | null;
|
||||||
source: SourceKind;
|
source: SourceKind;
|
||||||
requestedBy: { id: string; username: string };
|
requestedBy: { id: string; username: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerState {
|
export interface PlayerState {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
serverName: string | null;
|
serverName: string | null;
|
||||||
voiceChannelId: string | null;
|
voiceChannelId: string | null;
|
||||||
voiceChannelName: string | null;
|
voiceChannelName: string | null;
|
||||||
status: PlayerStatus;
|
status: PlayerStatus;
|
||||||
current: Track | null;
|
current: Track | null;
|
||||||
position: number;
|
position: number;
|
||||||
queue: Track[];
|
queue: Track[];
|
||||||
history: Track[];
|
history: Track[];
|
||||||
volume: number;
|
volume: number;
|
||||||
loop: LoopMode;
|
loop: LoopMode;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VoiceChannel {
|
export interface VoiceChannel {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerRef {
|
export interface ServerRef {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
iconUrl: string | null;
|
iconUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Me {
|
export interface Me {
|
||||||
user: { id: string; username: string };
|
user: { id: string; username: string };
|
||||||
servers: ServerRef[];
|
servers: ServerRef[];
|
||||||
features: { localLibrary: boolean };
|
features: { localLibrary: boolean; requireListener: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerStateResponse {
|
export interface ServerStateResponse {
|
||||||
state: PlayerState;
|
state: PlayerState;
|
||||||
voiceChannels: VoiceChannel[];
|
voiceChannels: VoiceChannel[];
|
||||||
yourVoiceChannel: VoiceChannel | null;
|
yourVoiceChannel: VoiceChannel | null;
|
||||||
canControl: boolean;
|
canControl: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user