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
+229
-223
@@ -1,223 +1,229 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "./api";
|
||||
import { Login } from "./components/Login";
|
||||
import { NowPlaying } from "./components/NowPlaying";
|
||||
import { QueueList } from "./components/QueueList";
|
||||
import { SearchPanel } from "./components/SearchPanel";
|
||||
import type { Me, PlayerState, ServerStateResponse, VoiceChannel } from "./types";
|
||||
|
||||
const SERVER_KEY = "mbot.server";
|
||||
|
||||
export function App() {
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [serverId, setServerId] = useState<string | null>(null);
|
||||
const [state, setState] = useState<PlayerState | null>(null);
|
||||
const [position, setPosition] = useState(0);
|
||||
const [voiceChannels, setVoiceChannels] = useState<VoiceChannel[]>([]);
|
||||
const [yourVoiceChannel, setYourVoiceChannel] = useState<VoiceChannel | null>(null);
|
||||
const [canControl, setCanControl] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
|
||||
/** Consumes the one-time link issued by the `!panel` chat command. */
|
||||
const consumeLinkToken = useCallback(async (): Promise<string | null> => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = params.get("token");
|
||||
if (!token) return null;
|
||||
try {
|
||||
const result = await api.loginWithLink(token);
|
||||
window.history.replaceState({}, "", "/");
|
||||
return result.serverId;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ссылка недействительна");
|
||||
window.history.replaceState({}, "", "/");
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMe = useCallback(
|
||||
async (preferredServer?: string | null) => {
|
||||
try {
|
||||
const profile = await api.me();
|
||||
setMe(profile);
|
||||
const stored = preferredServer ?? localStorage.getItem(SERVER_KEY);
|
||||
const chosen = profile.servers.find((server) => server.id === stored) ?? profile.servers[0];
|
||||
setServerId(chosen?.id ?? null);
|
||||
} catch {
|
||||
setMe(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const fromLink = await consumeLinkToken();
|
||||
await loadMe(fromLink);
|
||||
})();
|
||||
}, [consumeLinkToken, loadMe]);
|
||||
|
||||
const applyServerState = useCallback((payload: ServerStateResponse) => {
|
||||
setState(payload.state);
|
||||
setPosition(payload.state.position);
|
||||
setVoiceChannels(payload.voiceChannels);
|
||||
setYourVoiceChannel(payload.yourVoiceChannel);
|
||||
setCanControl(payload.canControl);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
applyServerState(await api.state(id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Не удалось получить состояние");
|
||||
}
|
||||
},
|
||||
[applyServerState],
|
||||
);
|
||||
|
||||
// Live updates: the socket carries both full snapshots and 1 Hz position ticks.
|
||||
useEffect(() => {
|
||||
if (!serverId) return;
|
||||
localStorage.setItem(SERVER_KEY, serverId);
|
||||
void refresh(serverId);
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const socket = new WebSocket(`${protocol}://${window.location.host}/ws?server=${serverId}`);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const payload = JSON.parse(event.data as string) as
|
||||
| { type: "state"; state: PlayerState }
|
||||
| { type: "position"; position: number };
|
||||
if (payload.type === "state") {
|
||||
setState(payload.state);
|
||||
setPosition(payload.state.position);
|
||||
} else {
|
||||
setPosition(payload.position);
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [serverId, refresh]);
|
||||
|
||||
const runAction = useCallback(
|
||||
async (action: string, payload: Record<string, unknown> = {}) => {
|
||||
if (!serverId) return;
|
||||
setError(null);
|
||||
try {
|
||||
await api.action(serverId, action, payload);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Действие не выполнено");
|
||||
}
|
||||
},
|
||||
[serverId],
|
||||
);
|
||||
|
||||
if (loading) return <div className="login-wrap">Загрузка…</div>;
|
||||
if (!me) return <Login onSuccess={() => void loadMe()} />;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<span className="dot" />
|
||||
Stoat Music
|
||||
</div>
|
||||
|
||||
{me.servers.length > 0 && (
|
||||
<select value={serverId ?? ""} onChange={(event) => setServerId(event.target.value)}>
|
||||
{me.servers.map((server) => (
|
||||
<option key={server.id} value={server.id}>
|
||||
{server.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<div className="spacer" />
|
||||
<span className="who">{me.user.username}</span>
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={async () => {
|
||||
await api.logout();
|
||||
setMe(null);
|
||||
}}
|
||||
>
|
||||
Выйти
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{!serverId || !state ? (
|
||||
<div className="card empty">
|
||||
Бот не состоит ни в одном общем с вами сервере. Пригласите его и обновите страницу.
|
||||
</div>
|
||||
) : (
|
||||
<div className="layout">
|
||||
<div>
|
||||
<SearchPanel
|
||||
serverId={serverId}
|
||||
canControl={canControl}
|
||||
localLibrary={me.features.localLibrary}
|
||||
onError={setError}
|
||||
/>
|
||||
<QueueList
|
||||
tracks={state.queue}
|
||||
canControl={canControl}
|
||||
onRemove={(track) => {
|
||||
void api.removeTrack(serverId, track.id).catch((err: Error) => setError(err.message));
|
||||
}}
|
||||
onMove={(track, index) => {
|
||||
void api.moveTrack(serverId, track.id, index).catch((err: Error) => setError(err.message));
|
||||
}}
|
||||
onClear={() => void runAction("clear")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NowPlaying
|
||||
state={state}
|
||||
position={position}
|
||||
canControl={canControl}
|
||||
voiceChannels={voiceChannels}
|
||||
yourVoiceChannel={yourVoiceChannel}
|
||||
onAction={(action, payload) => void runAction(action, payload)}
|
||||
onSeek={(seconds) => void runAction("seek", { position: seconds })}
|
||||
onJoin={(channelId) => {
|
||||
void api
|
||||
.join(serverId, channelId)
|
||||
.then(() => refresh(serverId))
|
||||
.catch((err: Error) => setError(err.message));
|
||||
}}
|
||||
/>
|
||||
|
||||
{state.history.length > 0 && (
|
||||
<div className="card">
|
||||
<h2>История</h2>
|
||||
<ul className="track-list">
|
||||
{state.history.map((track, index) => (
|
||||
<li className="track" key={`${track.id}-${index}`}>
|
||||
<span className="idx">{index + 1}</span>
|
||||
<div className="info">
|
||||
<div className="title">{track.title}</div>
|
||||
<div className="sub">{track.author ?? ""}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "./api";
|
||||
import { Login } from "./components/Login";
|
||||
import { NowPlaying } from "./components/NowPlaying";
|
||||
import { QueueList } from "./components/QueueList";
|
||||
import { SearchPanel } from "./components/SearchPanel";
|
||||
import type { Me, PlayerState, ServerStateResponse, VoiceChannel } from "./types";
|
||||
|
||||
const SERVER_KEY = "mbot.server";
|
||||
|
||||
export function App() {
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [serverId, setServerId] = useState<string | null>(null);
|
||||
const [state, setState] = useState<PlayerState | null>(null);
|
||||
const [position, setPosition] = useState(0);
|
||||
const [voiceChannels, setVoiceChannels] = useState<VoiceChannel[]>([]);
|
||||
const [yourVoiceChannel, setYourVoiceChannel] = useState<VoiceChannel | null>(null);
|
||||
const [canControl, setCanControl] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
|
||||
/** Consumes the one-time link issued by the `!panel` chat command. */
|
||||
const consumeLinkToken = useCallback(async (): Promise<string | null> => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = params.get("token");
|
||||
if (!token) return null;
|
||||
try {
|
||||
const result = await api.loginWithLink(token);
|
||||
window.history.replaceState({}, "", "/");
|
||||
return result.serverId;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ссылка недействительна");
|
||||
window.history.replaceState({}, "", "/");
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMe = useCallback(
|
||||
async (preferredServer?: string | null) => {
|
||||
try {
|
||||
const profile = await api.me();
|
||||
setMe(profile);
|
||||
const stored = preferredServer ?? localStorage.getItem(SERVER_KEY);
|
||||
const chosen = profile.servers.find((server) => server.id === stored) ?? profile.servers[0];
|
||||
setServerId(chosen?.id ?? null);
|
||||
} catch {
|
||||
setMe(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const fromLink = await consumeLinkToken();
|
||||
await loadMe(fromLink);
|
||||
})();
|
||||
}, [consumeLinkToken, loadMe]);
|
||||
|
||||
const applyServerState = useCallback((payload: ServerStateResponse) => {
|
||||
setState(payload.state);
|
||||
setPosition(payload.state.position);
|
||||
setVoiceChannels(payload.voiceChannels);
|
||||
setYourVoiceChannel(payload.yourVoiceChannel);
|
||||
setCanControl(payload.canControl);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
applyServerState(await api.state(id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Не удалось получить состояние");
|
||||
}
|
||||
},
|
||||
[applyServerState],
|
||||
);
|
||||
|
||||
// Live updates: the socket carries both full snapshots and 1 Hz position ticks.
|
||||
useEffect(() => {
|
||||
if (!serverId) return;
|
||||
localStorage.setItem(SERVER_KEY, serverId);
|
||||
void refresh(serverId);
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const socket = new WebSocket(`${protocol}://${window.location.host}/ws?server=${serverId}`);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const payload = JSON.parse(event.data as string) as
|
||||
| { type: "state"; state: PlayerState }
|
||||
| { type: "position"; position: number }
|
||||
| { type: "presence"; yourVoiceChannel: VoiceChannel | null; voiceChannels: VoiceChannel[] };
|
||||
if (payload.type === "state") {
|
||||
setState(payload.state);
|
||||
setPosition(payload.state.position);
|
||||
} else if (payload.type === "presence") {
|
||||
setYourVoiceChannel(payload.yourVoiceChannel);
|
||||
setVoiceChannels(payload.voiceChannels);
|
||||
} else {
|
||||
setPosition(payload.position);
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [serverId, refresh]);
|
||||
|
||||
const runAction = useCallback(
|
||||
async (action: string, payload: Record<string, unknown> = {}) => {
|
||||
if (!serverId) return;
|
||||
setError(null);
|
||||
try {
|
||||
await api.action(serverId, action, payload);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Действие не выполнено");
|
||||
}
|
||||
},
|
||||
[serverId],
|
||||
);
|
||||
|
||||
if (loading) return <div className="login-wrap">Загрузка…</div>;
|
||||
if (!me) return <Login onSuccess={() => void loadMe()} />;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<span className="dot" />
|
||||
Stoat Music
|
||||
</div>
|
||||
|
||||
{me.servers.length > 0 && (
|
||||
<select value={serverId ?? ""} onChange={(event) => setServerId(event.target.value)}>
|
||||
{me.servers.map((server) => (
|
||||
<option key={server.id} value={server.id}>
|
||||
{server.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<div className="spacer" />
|
||||
<span className="who">{me.user.username}</span>
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={async () => {
|
||||
await api.logout();
|
||||
setMe(null);
|
||||
}}
|
||||
>
|
||||
Выйти
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{!serverId || !state ? (
|
||||
<div className="card empty">
|
||||
Бот не состоит ни в одном общем с вами сервере. Пригласите его и обновите страницу.
|
||||
</div>
|
||||
) : (
|
||||
<div className="layout">
|
||||
<div>
|
||||
<SearchPanel
|
||||
serverId={serverId}
|
||||
canControl={canControl}
|
||||
localLibrary={me.features.localLibrary}
|
||||
onError={setError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NowPlaying
|
||||
state={state}
|
||||
position={position}
|
||||
canControl={canControl}
|
||||
voiceChannels={voiceChannels}
|
||||
yourVoiceChannel={yourVoiceChannel}
|
||||
requireListener={me.features.requireListener}
|
||||
onAction={(action, payload) => void runAction(action, payload)}
|
||||
onSeek={(seconds) => void runAction("seek", { position: seconds })}
|
||||
onJoin={(channelId) => {
|
||||
void api
|
||||
.join(serverId, channelId)
|
||||
.then(() => refresh(serverId))
|
||||
.catch((err: Error) => setError(err.message));
|
||||
}}
|
||||
/>
|
||||
|
||||
<QueueList
|
||||
tracks={state.queue}
|
||||
canControl={canControl}
|
||||
onRemove={(track) => {
|
||||
void api.removeTrack(serverId, track.id).catch((err: Error) => setError(err.message));
|
||||
}}
|
||||
onMove={(track, index) => {
|
||||
void api.moveTrack(serverId, track.id, index).catch((err: Error) => setError(err.message));
|
||||
}}
|
||||
onClear={() => void runAction("clear")}
|
||||
/>
|
||||
|
||||
{state.history.length > 0 && (
|
||||
<div className="card">
|
||||
<h2>История</h2>
|
||||
<ul className="track-list">
|
||||
{state.history.map((track, index) => (
|
||||
<li className="track" key={`${track.id}-${index}`}>
|
||||
<span className="idx">{index + 1}</span>
|
||||
<div className="info">
|
||||
<div className="title">{track.title}</div>
|
||||
<div className="sub">{track.author ?? ""}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+91
-83
@@ -1,83 +1,91 @@
|
||||
import type { Me, ServerStateResponse, Track } from "./types";
|
||||
|
||||
export class ApiError extends Error {}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
headers: init.body ? { "content-type": "application/json" } : undefined,
|
||||
...init,
|
||||
});
|
||||
const text = await res.text();
|
||||
const body = text ? JSON.parse(text) : null;
|
||||
if (!res.ok) throw new ApiError(body?.error ?? `Ошибка ${res.status}`);
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
user?: { id: string; username: string };
|
||||
mfaRequired?: boolean;
|
||||
ticket?: string;
|
||||
methods?: string[];
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => request<Me>("/api/me"),
|
||||
|
||||
login: (payload: {
|
||||
email?: string;
|
||||
password?: string;
|
||||
mfaTicket?: string;
|
||||
totpCode?: string;
|
||||
recoveryCode?: string;
|
||||
}) => request<LoginResponse>("/api/auth/login", { method: "POST", body: JSON.stringify(payload) }),
|
||||
|
||||
loginWithLink: (token: string) =>
|
||||
request<{ user: { id: string; username: string }; serverId: string | null }>("/api/auth/link", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
}),
|
||||
|
||||
logout: () => request<{ ok: true }>("/api/auth/logout", { method: "POST" }),
|
||||
|
||||
state: (serverId: string) => request<ServerStateResponse>(`/api/servers/${serverId}/state`),
|
||||
|
||||
search: (serverId: string, query: string) =>
|
||||
request<{ tracks: Track[] }>(`/api/servers/${serverId}/search?q=${encodeURIComponent(query)}`),
|
||||
|
||||
play: (
|
||||
serverId: string,
|
||||
payload: { query?: string; trackIds?: string[]; mode?: "append" | "next" | "now"; voiceChannelId?: string | null },
|
||||
) => request<{ ok: true }>(`/api/servers/${serverId}/play`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
|
||||
action: (serverId: string, action: string, payload: Record<string, unknown> = {}) =>
|
||||
request<{ ok: true }>(`/api/servers/${serverId}/actions/${action}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
join: (serverId: string, voiceChannelId: string | null) =>
|
||||
request<{ ok: true }>(`/api/servers/${serverId}/join`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ voiceChannelId }),
|
||||
}),
|
||||
|
||||
removeTrack: (serverId: string, trackId: string) =>
|
||||
request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}`, { method: "DELETE" }),
|
||||
|
||||
moveTrack: (serverId: string, trackId: string, index: number) =>
|
||||
request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}/move`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ index }),
|
||||
}),
|
||||
};
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return "LIVE";
|
||||
const total = Math.floor(seconds);
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const secs = total % 60;
|
||||
const pad = (value: number) => value.toString().padStart(2, "0");
|
||||
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`;
|
||||
}
|
||||
import type { Me, ServerStateResponse, Track } from "./types";
|
||||
|
||||
export class ApiError extends Error {}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
headers: init.body ? { "content-type": "application/json" } : undefined,
|
||||
...init,
|
||||
});
|
||||
const text = await res.text();
|
||||
const body = text ? JSON.parse(text) : null;
|
||||
if (!res.ok) throw new ApiError(body?.error ?? `Ошибка ${res.status}`);
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
user?: { id: string; username: string };
|
||||
mfaRequired?: boolean;
|
||||
ticket?: string;
|
||||
methods?: string[];
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => request<Me>("/api/me"),
|
||||
|
||||
login: (payload: {
|
||||
email?: string;
|
||||
password?: string;
|
||||
mfaTicket?: string;
|
||||
totpCode?: string;
|
||||
recoveryCode?: string;
|
||||
}) => request<LoginResponse>("/api/auth/login", { method: "POST", body: JSON.stringify(payload) }),
|
||||
|
||||
loginWithLink: (token: string) =>
|
||||
request<{ user: { id: string; username: string }; serverId: string | null }>("/api/auth/link", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
}),
|
||||
|
||||
logout: () => request<{ ok: true }>("/api/auth/logout", { method: "POST" }),
|
||||
|
||||
state: (serverId: string) => request<ServerStateResponse>(`/api/servers/${serverId}/state`),
|
||||
|
||||
search: (serverId: string, query: string) =>
|
||||
request<{ tracks: Track[] }>(`/api/servers/${serverId}/search?q=${encodeURIComponent(query)}`),
|
||||
|
||||
play: (
|
||||
serverId: string,
|
||||
payload: { query?: string; trackIds?: string[]; mode?: "append" | "next" | "now"; voiceChannelId?: string | null },
|
||||
) => request<{ ok: true }>(`/api/servers/${serverId}/play`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
|
||||
action: (serverId: string, action: string, payload: Record<string, unknown> = {}) =>
|
||||
request<{ ok: true }>(`/api/servers/${serverId}/actions/${action}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
join: (serverId: string, voiceChannelId: string | null) =>
|
||||
request<{ ok: true }>(`/api/servers/${serverId}/join`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ voiceChannelId }),
|
||||
}),
|
||||
|
||||
removeTrack: (serverId: string, trackId: string) =>
|
||||
request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}`, { method: "DELETE" }),
|
||||
|
||||
moveTrack: (serverId: string, trackId: string, index: number) =>
|
||||
request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}/move`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ index }),
|
||||
}),
|
||||
};
|
||||
|
||||
/** Clock formatting for any position or length; 0 is a legitimate "0:00". */
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
|
||||
const total = Math.floor(seconds);
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const secs = total % 60;
|
||||
const pad = (value: number) => value.toString().padStart(2, "0");
|
||||
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`;
|
||||
}
|
||||
|
||||
/** Track length as shown to people: live streams and unknown lengths are not clocks. */
|
||||
export function formatLength(track: { duration: number; isLive: boolean }): string {
|
||||
if (track.isLive) return "LIVE";
|
||||
if (track.duration <= 0) return "—";
|
||||
return formatDuration(track.duration);
|
||||
}
|
||||
|
||||
+200
-177
@@ -1,177 +1,200 @@
|
||||
import { useEffect, useState, type MouseEvent } from "react";
|
||||
import { formatDuration } from "../api";
|
||||
import type { LoopMode, PlayerState, VoiceChannel } from "../types";
|
||||
|
||||
interface Props {
|
||||
state: PlayerState;
|
||||
position: number;
|
||||
canControl: boolean;
|
||||
voiceChannels: VoiceChannel[];
|
||||
yourVoiceChannel: VoiceChannel | null;
|
||||
onAction(action: string, payload?: Record<string, unknown>): void;
|
||||
onSeek(seconds: number): void;
|
||||
onJoin(channelId: string | null): void;
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<PlayerState["status"], string> = {
|
||||
idle: "ожидание",
|
||||
connecting: "подключение",
|
||||
buffering: "буферизация",
|
||||
playing: "играет",
|
||||
paused: "пауза",
|
||||
};
|
||||
|
||||
const LOOP_LABEL: Record<LoopMode, string> = {
|
||||
off: "🔁 выкл",
|
||||
track: "🔂 трек",
|
||||
queue: "🔁 очередь",
|
||||
};
|
||||
|
||||
export function NowPlaying({
|
||||
state,
|
||||
position,
|
||||
canControl,
|
||||
voiceChannels,
|
||||
yourVoiceChannel,
|
||||
onAction,
|
||||
onSeek,
|
||||
onJoin,
|
||||
}: Props) {
|
||||
const [volume, setVolume] = useState(state.volume);
|
||||
const [channelId, setChannelId] = useState<string>(
|
||||
state.voiceChannelId ?? yourVoiceChannel?.id ?? voiceChannels[0]?.id ?? "",
|
||||
);
|
||||
|
||||
useEffect(() => setVolume(state.volume), [state.volume]);
|
||||
useEffect(() => {
|
||||
if (state.voiceChannelId) setChannelId(state.voiceChannelId);
|
||||
}, [state.voiceChannelId]);
|
||||
|
||||
const track = state.current;
|
||||
const duration = track?.duration ?? 0;
|
||||
const ratio = duration > 0 ? Math.min(1, position / duration) : 0;
|
||||
const isPlaying = state.status === "playing" || state.status === "buffering";
|
||||
|
||||
function seekFromClick(event: MouseEvent<HTMLDivElement>) {
|
||||
if (!track || duration <= 0 || !canControl) return;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const fraction = (event.clientX - rect.left) / rect.width;
|
||||
onSeek(Math.max(0, Math.min(duration - 1, fraction * duration)));
|
||||
}
|
||||
|
||||
const nextLoop: LoopMode = state.loop === "off" ? "track" : state.loop === "track" ? "queue" : "off";
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>
|
||||
Сейчас играет{" "}
|
||||
<span className={`status-pill ${state.status === "playing" ? "playing" : ""}`}>
|
||||
{STATUS_LABEL[state.status]}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div className="now">
|
||||
{track?.thumbnail ? (
|
||||
<img className="cover" src={track.thumbnail} alt="" />
|
||||
) : (
|
||||
<div className="cover placeholder">🎵</div>
|
||||
)}
|
||||
<div className="now-meta">
|
||||
<div className="now-title">
|
||||
{track ? (
|
||||
/^https?:/.test(track.url) ? (
|
||||
<a href={track.url} target="_blank" rel="noreferrer noopener">
|
||||
{track.title}
|
||||
</a>
|
||||
) : (
|
||||
track.title
|
||||
)
|
||||
) : (
|
||||
"Тишина"
|
||||
)}
|
||||
</div>
|
||||
<div className="now-sub">
|
||||
{track
|
||||
? [track.author, `запросил ${track.requestedBy.username}`].filter(Boolean).join(" · ")
|
||||
: "Очередь пуста — найдите что-нибудь слева"}
|
||||
</div>
|
||||
<div className="progress">
|
||||
<div className="bar" onClick={seekFromClick}>
|
||||
<span style={{ width: `${ratio * 100}%` }} />
|
||||
</div>
|
||||
<div className="times">
|
||||
<span>{track ? formatDuration(position) : "0:00"}</span>
|
||||
<span>{track?.isLive ? "LIVE" : formatDuration(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="controls">
|
||||
<button
|
||||
className="icon big primary"
|
||||
onClick={() => onAction("toggle")}
|
||||
disabled={!canControl || !track}
|
||||
title={isPlaying ? "Пауза" : "Играть"}
|
||||
>
|
||||
{isPlaying ? "⏸" : "▶"}
|
||||
</button>
|
||||
<button className="icon" onClick={() => onAction("skip")} disabled={!canControl} title="Следующий">
|
||||
⏭
|
||||
</button>
|
||||
<button className="icon" onClick={() => onAction("stop")} disabled={!canControl} title="Стоп">
|
||||
⏹
|
||||
</button>
|
||||
<button
|
||||
className="icon"
|
||||
onClick={() => onAction("shuffle")}
|
||||
disabled={!canControl || state.queue.length < 2}
|
||||
title="Перемешать"
|
||||
>
|
||||
🔀
|
||||
</button>
|
||||
<button
|
||||
className={state.loop === "off" ? "" : "active"}
|
||||
onClick={() => onAction("loop", { mode: nextLoop })}
|
||||
disabled={!canControl}
|
||||
title="Режим повтора"
|
||||
>
|
||||
{LOOP_LABEL[state.loop]}
|
||||
</button>
|
||||
|
||||
<div className="volume">
|
||||
<span title="Громкость">🔊</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={200}
|
||||
value={volume}
|
||||
disabled={!canControl}
|
||||
onChange={(event) => setVolume(Number(event.target.value))}
|
||||
onMouseUp={() => onAction("volume", { volume })}
|
||||
onTouchEnd={() => onAction("volume", { volume })}
|
||||
/>
|
||||
<span className="badge">{volume}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="voice-row">
|
||||
<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(channelId || null)} disabled={!canControl || !channelId}>
|
||||
{state.voiceChannelId === channelId ? "Переподключить" : "Зайти"}
|
||||
</button>
|
||||
<button onClick={() => onAction("leave")} disabled={!canControl || !state.voiceChannelId}>
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useEffect, useState, type MouseEvent } from "react";
|
||||
import { formatDuration, formatLength } from "../api";
|
||||
import type { LoopMode, PlayerState, VoiceChannel } from "../types";
|
||||
|
||||
interface Props {
|
||||
state: PlayerState;
|
||||
position: number;
|
||||
canControl: boolean;
|
||||
voiceChannels: VoiceChannel[];
|
||||
yourVoiceChannel: VoiceChannel | null;
|
||||
requireListener: boolean;
|
||||
onAction(action: string, payload?: Record<string, unknown>): void;
|
||||
onSeek(seconds: number): void;
|
||||
onJoin(channelId: string | null): void;
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<PlayerState["status"], string> = {
|
||||
idle: "ожидание",
|
||||
connecting: "подключение",
|
||||
buffering: "буферизация",
|
||||
playing: "играет",
|
||||
paused: "пауза",
|
||||
};
|
||||
|
||||
const LOOP_LABEL: Record<LoopMode, string> = {
|
||||
off: "🔁 выкл",
|
||||
track: "🔂 трек",
|
||||
queue: "🔁 очередь",
|
||||
};
|
||||
|
||||
export function NowPlaying({
|
||||
state,
|
||||
position,
|
||||
canControl,
|
||||
voiceChannels,
|
||||
yourVoiceChannel,
|
||||
requireListener,
|
||||
onAction,
|
||||
onSeek,
|
||||
onJoin,
|
||||
}: Props) {
|
||||
const [volume, setVolume] = useState(state.volume);
|
||||
const [channelId, setChannelId] = useState<string>(
|
||||
state.voiceChannelId ?? yourVoiceChannel?.id ?? voiceChannels[0]?.id ?? "",
|
||||
);
|
||||
|
||||
useEffect(() => setVolume(state.volume), [state.volume]);
|
||||
useEffect(() => {
|
||||
if (state.voiceChannelId) setChannelId(state.voiceChannelId);
|
||||
}, [state.voiceChannelId]);
|
||||
|
||||
const track = state.current;
|
||||
const duration = track?.duration ?? 0;
|
||||
const ratio = duration > 0 ? Math.min(1, position / duration) : 0;
|
||||
const isPlaying = state.status === "playing" || state.status === "buffering";
|
||||
|
||||
function seekFromClick(event: MouseEvent<HTMLDivElement>) {
|
||||
if (!track || duration <= 0 || !canControl) return;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const fraction = (event.clientX - rect.left) / rect.width;
|
||||
onSeek(Math.max(0, Math.min(duration - 1, fraction * duration)));
|
||||
}
|
||||
|
||||
const nextLoop: LoopMode = state.loop === "off" ? "track" : state.loop === "track" ? "queue" : "off";
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>
|
||||
Сейчас играет{" "}
|
||||
<span className={`status-pill ${state.status === "playing" ? "playing" : ""}`}>
|
||||
{STATUS_LABEL[state.status]}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div className="now">
|
||||
{track?.thumbnail ? (
|
||||
<img className="cover" src={track.thumbnail} alt="" />
|
||||
) : (
|
||||
<div className="cover placeholder">🎵</div>
|
||||
)}
|
||||
<div className="now-meta">
|
||||
<div className="now-title">
|
||||
{track ? (
|
||||
/^https?:/.test(track.url) ? (
|
||||
<a href={track.url} target="_blank" rel="noreferrer noopener">
|
||||
{track.title}
|
||||
</a>
|
||||
) : (
|
||||
track.title
|
||||
)
|
||||
) : (
|
||||
"Тишина"
|
||||
)}
|
||||
</div>
|
||||
<div className="now-sub">
|
||||
{track
|
||||
? [track.author, `запросил ${track.requestedBy.username}`].filter(Boolean).join(" · ")
|
||||
: "Очередь пуста — найдите что-нибудь слева"}
|
||||
</div>
|
||||
<div className="progress">
|
||||
<div className="bar" onClick={seekFromClick}>
|
||||
<span style={{ width: `${ratio * 100}%` }} />
|
||||
</div>
|
||||
<div className="times">
|
||||
<span>{formatDuration(track ? position : 0)}</span>
|
||||
<span>{track ? formatLength(track) : "0:00"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="controls">
|
||||
<button
|
||||
className="icon big primary"
|
||||
onClick={() => onAction("toggle")}
|
||||
disabled={!canControl || !track}
|
||||
title={isPlaying ? "Пауза" : "Играть"}
|
||||
>
|
||||
{isPlaying ? "⏸" : "▶"}
|
||||
</button>
|
||||
<button className="icon" onClick={() => onAction("skip")} disabled={!canControl} title="Следующий">
|
||||
⏭
|
||||
</button>
|
||||
<button className="icon" onClick={() => onAction("stop")} disabled={!canControl} title="Стоп">
|
||||
⏹
|
||||
</button>
|
||||
<button
|
||||
className="icon"
|
||||
onClick={() => onAction("shuffle")}
|
||||
disabled={!canControl || state.queue.length < 2}
|
||||
title="Перемешать"
|
||||
>
|
||||
🔀
|
||||
</button>
|
||||
<button
|
||||
className={state.loop === "off" ? "" : "active"}
|
||||
onClick={() => onAction("loop", { mode: nextLoop })}
|
||||
disabled={!canControl}
|
||||
title="Режим повтора"
|
||||
>
|
||||
{LOOP_LABEL[state.loop]}
|
||||
</button>
|
||||
|
||||
<div className="volume">
|
||||
<span title="Громкость">🔊</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={200}
|
||||
value={volume}
|
||||
disabled={!canControl}
|
||||
onChange={(event) => setVolume(Number(event.target.value))}
|
||||
onMouseUp={() => onAction("volume", { volume })}
|
||||
onTouchEnd={() => onAction("volume", { volume })}
|
||||
/>
|
||||
<span className="badge">{volume}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="voice-row">
|
||||
{requireListener ? (
|
||||
// Playback follows the listener, so there is nothing to choose here:
|
||||
// the bot joins the channel you are sitting in.
|
||||
<span className="voice-status">
|
||||
{yourVoiceChannel ? (
|
||||
<>
|
||||
Вы в канале <strong>{yourVoiceChannel.name}</strong>
|
||||
</>
|
||||
) : (
|
||||
"Вы не в голосовом канале"
|
||||
)}
|
||||
{state.voiceChannelName ? ` · бот в «${state.voiceChannelName}»` : " · бот не в канале"}
|
||||
</span>
|
||||
) : (
|
||||
<select value={channelId} onChange={(event) => setChannelId(event.target.value)} disabled={!canControl}>
|
||||
{voiceChannels.length === 0 && <option value="">Нет голосовых каналов</option>}
|
||||
{voiceChannels.map((channel) => (
|
||||
<option key={channel.id} value={channel.id}>
|
||||
{channel.name}
|
||||
{yourVoiceChannel?.id === channel.id ? " (вы здесь)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onJoin(requireListener ? null : channelId || null)}
|
||||
disabled={!canControl || (requireListener ? !yourVoiceChannel : !channelId)}
|
||||
title={requireListener && !yourVoiceChannel ? "Сначала зайдите в голосовой канал" : undefined}
|
||||
>
|
||||
{state.voiceChannelId && state.voiceChannelId === (requireListener ? yourVoiceChannel?.id : channelId)
|
||||
? "Переподключить"
|
||||
: "Позвать"}
|
||||
</button>
|
||||
<button onClick={() => onAction("leave")} disabled={!canControl || !state.voiceChannelId}>
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,77 +1,75 @@
|
||||
import { formatDuration } from "../api";
|
||||
import type { Track } from "../types";
|
||||
|
||||
interface Props {
|
||||
tracks: Track[];
|
||||
canControl: boolean;
|
||||
onRemove(track: Track): void;
|
||||
onMove(track: Track, index: number): void;
|
||||
onClear(): void;
|
||||
}
|
||||
|
||||
export function QueueList({ tracks, canControl, onRemove, onMove, onClear }: Props) {
|
||||
const total = tracks.reduce((acc, track) => acc + track.duration, 0);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>
|
||||
Очередь · {tracks.length}
|
||||
{total > 0 ? ` · ${formatDuration(total)}` : ""}
|
||||
</h2>
|
||||
|
||||
{tracks.length === 0 ? (
|
||||
<div className="empty">Очередь пуста</div>
|
||||
) : (
|
||||
<>
|
||||
<ul className="track-list">
|
||||
{tracks.map((track, index) => (
|
||||
<li className="track" key={track.id}>
|
||||
<span className="idx">{index + 1}</span>
|
||||
{track.thumbnail ? <img className="thumb" src={track.thumbnail} alt="" /> : <div className="thumb" />}
|
||||
<div className="info">
|
||||
<div className="title">{track.title}</div>
|
||||
<div className="sub">
|
||||
{[track.author, track.isLive ? "LIVE" : formatDuration(track.duration), track.requestedBy.username]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button
|
||||
onClick={() => onMove(track, 0)}
|
||||
disabled={!canControl || index === 0}
|
||||
title="Наверх очереди"
|
||||
>
|
||||
⤒
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onMove(track, Math.max(0, index - 1))}
|
||||
disabled={!canControl || index === 0}
|
||||
title="Выше"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onMove(track, index + 1)}
|
||||
disabled={!canControl || index === tracks.length - 1}
|
||||
title="Ниже"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button onClick={() => onRemove(track)} disabled={!canControl} title="Убрать">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
<button className="ghost" onClick={onClear} disabled={!canControl}>
|
||||
Очистить очередь
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { formatDuration, formatLength } from "../api";
|
||||
import type { Track } from "../types";
|
||||
|
||||
interface Props {
|
||||
tracks: Track[];
|
||||
canControl: boolean;
|
||||
onRemove(track: Track): void;
|
||||
onMove(track: Track, index: number): void;
|
||||
onClear(): void;
|
||||
}
|
||||
|
||||
export function QueueList({ tracks, canControl, onRemove, onMove, onClear }: Props) {
|
||||
const total = tracks.reduce((acc, track) => acc + track.duration, 0);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>
|
||||
Очередь · {tracks.length}
|
||||
{total > 0 ? ` · ${formatDuration(total)}` : ""}
|
||||
</h2>
|
||||
|
||||
{tracks.length === 0 ? (
|
||||
<div className="empty">Очередь пуста</div>
|
||||
) : (
|
||||
<>
|
||||
<ul className="track-list">
|
||||
{tracks.map((track, index) => (
|
||||
<li className="track" key={track.id}>
|
||||
<span className="idx">{index + 1}</span>
|
||||
{track.thumbnail ? <img className="thumb" src={track.thumbnail} alt="" /> : <div className="thumb" />}
|
||||
<div className="info">
|
||||
<div className="title">{track.title}</div>
|
||||
<div className="sub">
|
||||
{[track.author, formatLength(track), track.requestedBy.username].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button
|
||||
onClick={() => onMove(track, 0)}
|
||||
disabled={!canControl || index === 0}
|
||||
title="Наверх очереди"
|
||||
>
|
||||
⤒
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onMove(track, Math.max(0, index - 1))}
|
||||
disabled={!canControl || index === 0}
|
||||
title="Выше"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onMove(track, index + 1)}
|
||||
disabled={!canControl || index === tracks.length - 1}
|
||||
title="Ниже"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button onClick={() => onRemove(track)} disabled={!canControl} title="Убрать">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
<button className="ghost" onClick={onClear} disabled={!canControl}>
|
||||
Очистить очередь
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+124
-117
@@ -1,117 +1,124 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { api, formatDuration } from "../api";
|
||||
import type { Track } from "../types";
|
||||
|
||||
const SOURCE_BADGE: Record<Track["source"], string> = {
|
||||
youtube: "YouTube",
|
||||
soundcloud: "SoundCloud",
|
||||
direct: "Ссылка",
|
||||
local: "Медиатека",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
canControl: boolean;
|
||||
localLibrary: boolean;
|
||||
onError(message: string | null): void;
|
||||
}
|
||||
|
||||
export function SearchPanel({ serverId, canControl, localLibrary, onError }: Props) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<Track[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [lastAdded, setLastAdded] = useState<string | null>(null);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const value = query.trim();
|
||||
if (!value) return;
|
||||
setBusy(true);
|
||||
onError(null);
|
||||
try {
|
||||
if (/^https?:\/\//i.test(value)) {
|
||||
await api.play(serverId, { query: value });
|
||||
setLastAdded(value);
|
||||
setResults([]);
|
||||
setQuery("");
|
||||
return;
|
||||
}
|
||||
const { tracks } = await api.search(serverId, value);
|
||||
setResults(tracks);
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : "Поиск не удался");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueue(track: Track, mode: "append" | "next" | "now") {
|
||||
onError(null);
|
||||
try {
|
||||
await api.play(serverId, { trackIds: [track.id], mode });
|
||||
setLastAdded(track.title);
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : "Не удалось добавить трек");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Поиск</h2>
|
||||
<form className="search-form" onSubmit={submit}>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Название трека или ссылка…"
|
||||
disabled={!canControl}
|
||||
/>
|
||||
<button className="primary" type="submit" disabled={busy || !canControl}>
|
||||
{busy ? "…" : "Найти"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{results.length === 0 ? (
|
||||
<div className="empty">
|
||||
{lastAdded ? `Добавлено: ${lastAdded}` : "Введите запрос или вставьте ссылку — плейлисты тоже поддерживаются."}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="track-list">
|
||||
{results.map((track, index) => (
|
||||
<li className="track" key={track.id}>
|
||||
<span className="idx">{index + 1}</span>
|
||||
{track.thumbnail ? <img className="thumb" src={track.thumbnail} alt="" /> : <div className="thumb" />}
|
||||
<div className="info">
|
||||
<div className="title">{track.title}</div>
|
||||
<div className="sub">
|
||||
{[track.author, track.isLive ? "LIVE" : formatDuration(track.duration)].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
<span className="badge">{SOURCE_BADGE[track.source]}</span>
|
||||
<div className="actions">
|
||||
<button onClick={() => enqueue(track, "now")} disabled={!canControl} title="Играть сейчас">
|
||||
▶
|
||||
</button>
|
||||
<button onClick={() => enqueue(track, "next")} disabled={!canControl} title="Следующим">
|
||||
⤴
|
||||
</button>
|
||||
<button onClick={() => enqueue(track, "append")} disabled={!canControl} title="В очередь">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p className="hint">
|
||||
Префиксы: <code>sc:</code> — искать в SoundCloud, <code>yt:</code> — в YouTube
|
||||
{localLibrary ? (
|
||||
<>
|
||||
, <code>local:</code> — в локальной медиатеке
|
||||
</>
|
||||
) : null}
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { api, formatLength } from "../api";
|
||||
import type { Track } from "../types";
|
||||
|
||||
const SOURCE_BADGE: Record<Track["source"], string> = {
|
||||
youtube: "YouTube",
|
||||
soundcloud: "SoundCloud",
|
||||
direct: "Ссылка",
|
||||
local: "Медиатека",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
canControl: boolean;
|
||||
localLibrary: boolean;
|
||||
onError(message: string | null): void;
|
||||
}
|
||||
|
||||
export function SearchPanel({ serverId, canControl, localLibrary, onError }: Props) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<Track[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [lastAdded, setLastAdded] = useState<string | null>(null);
|
||||
const [searched, setSearched] = useState(false);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const value = query.trim();
|
||||
if (!value) return;
|
||||
setBusy(true);
|
||||
onError(null);
|
||||
try {
|
||||
if (/^https?:\/\//i.test(value)) {
|
||||
await api.play(serverId, { query: value });
|
||||
setLastAdded(value);
|
||||
setResults([]);
|
||||
setSearched(false);
|
||||
setQuery("");
|
||||
return;
|
||||
}
|
||||
const { tracks } = await api.search(serverId, value);
|
||||
setResults(tracks);
|
||||
setSearched(true);
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : "Поиск не удался");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueue(track: Track, mode: "append" | "next" | "now") {
|
||||
onError(null);
|
||||
try {
|
||||
await api.play(serverId, { trackIds: [track.id], mode });
|
||||
setLastAdded(track.title);
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : "Не удалось добавить трек");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card grow">
|
||||
<h2>Поиск</h2>
|
||||
<form className="search-form" onSubmit={submit}>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Название трека или ссылка…"
|
||||
disabled={!canControl}
|
||||
/>
|
||||
<button className="primary" type="submit" disabled={busy || !canControl}>
|
||||
{busy ? "…" : "Найти"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{results.length === 0 ? (
|
||||
<div className="empty">
|
||||
{searched
|
||||
? `По запросу «${query}» ничего не нашлось`
|
||||
: lastAdded
|
||||
? `Добавлено: ${lastAdded}`
|
||||
: "Введите запрос или вставьте ссылку — плейлисты тоже поддерживаются."}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="track-list">
|
||||
{results.map((track, index) => (
|
||||
<li className="track" key={track.id}>
|
||||
<span className="idx">{index + 1}</span>
|
||||
{track.thumbnail ? <img className="thumb" src={track.thumbnail} alt="" /> : <div className="thumb" />}
|
||||
<div className="info">
|
||||
<div className="title">{track.title}</div>
|
||||
<div className="sub">
|
||||
{[track.author, formatLength(track)].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
<span className="badge">{SOURCE_BADGE[track.source]}</span>
|
||||
<div className="actions">
|
||||
<button onClick={() => enqueue(track, "now")} disabled={!canControl} title="Играть сейчас">
|
||||
▶
|
||||
</button>
|
||||
<button onClick={() => enqueue(track, "next")} disabled={!canControl} title="Следующим">
|
||||
⤴
|
||||
</button>
|
||||
<button onClick={() => enqueue(track, "append")} disabled={!canControl} title="В очередь">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p className="hint">
|
||||
Префиксы: <code>sc:</code> — искать в SoundCloud, <code>yt:</code> — в YouTube
|
||||
{localLibrary ? (
|
||||
<>
|
||||
, <code>local:</code> — в локальной медиатеке
|
||||
</>
|
||||
) : null}
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+511
-475
@@ -1,475 +1,511 @@
|
||||
:root {
|
||||
--bg: #0f1014;
|
||||
--bg-elev: #171922;
|
||||
--bg-elev-2: #1e2130;
|
||||
--line: #2a2e3f;
|
||||
--text: #e8eaf2;
|
||||
--muted: #9aa0b5;
|
||||
--accent: #7b6cf6;
|
||||
--accent-soft: rgba(123, 108, 246, 0.16);
|
||||
--danger: #f2555a;
|
||||
--ok: #3ecf8e;
|
||||
--radius: 14px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: radial-gradient(1200px 600px at 20% -10%, #1b1e2e 0%, var(--bg) 60%);
|
||||
color: var(--text);
|
||||
font: 15px/1.5 "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-elev-2);
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, transform 0.05s ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #262a3c;
|
||||
border-color: #3a3f56;
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button.primary:hover:not(:disabled) {
|
||||
background: #8b7dff;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
button.icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
button.icon.big {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
button.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
color: #cfc7ff;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--bg-elev-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: 2px solid var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 18px 60px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 4px 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.01em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.brand .dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 14px var(--accent);
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.topbar select {
|
||||
width: auto;
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
.who {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.card + .card {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.now {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
background: var(--bg-elev-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cover.placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 30px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.now-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.now-title {
|
||||
font-size: 19px;
|
||||
font-weight: 650;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.now-title a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.now-title a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.now-sub {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 8px;
|
||||
border-radius: 99px;
|
||||
background: var(--bg-elev-2);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar > span {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: linear-gradient(90deg, var(--accent), #a78bfa);
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
.times {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
min-width: 170px;
|
||||
}
|
||||
|
||||
.volume input[type="range"] {
|
||||
width: 110px;
|
||||
padding: 0;
|
||||
accent-color: var(--accent);
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
font-size: 12px;
|
||||
padding: 4px 9px;
|
||||
border-radius: 99px;
|
||||
background: var(--bg-elev-2);
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.status-pill.playing {
|
||||
color: var(--ok);
|
||||
border-color: rgba(62, 207, 142, 0.4);
|
||||
}
|
||||
|
||||
.track-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.track:hover {
|
||||
background: var(--bg-elev-2);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.track .idx {
|
||||
width: 22px;
|
||||
text-align: right;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.track .thumb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
background: var(--bg-elev-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.track .info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.track .title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track .sub {
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track .actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.track:hover .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.track .actions button {
|
||||
padding: 5px 9px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-elev-2);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
padding: 26px 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(242, 85, 90, 0.12);
|
||||
border: 1px solid rgba(242, 85, 90, 0.4);
|
||||
color: #ffb4b6;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-wrap {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.login h1 {
|
||||
font-size: 22px;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.login p.sub {
|
||||
color: var(--muted);
|
||||
margin: 0 0 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: 12px 0 6px;
|
||||
}
|
||||
|
||||
.login button {
|
||||
width: 100%;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.voice-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.voice-row select {
|
||||
flex: 1;
|
||||
}
|
||||
:root {
|
||||
--bg: #0f1014;
|
||||
--bg-elev: #171922;
|
||||
--bg-elev-2: #1e2130;
|
||||
--line: #2a2e3f;
|
||||
--text: #e8eaf2;
|
||||
--muted: #9aa0b5;
|
||||
--accent: #7b6cf6;
|
||||
--accent-soft: rgba(123, 108, 246, 0.16);
|
||||
--danger: #f2555a;
|
||||
--ok: #3ecf8e;
|
||||
--radius: 14px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: radial-gradient(1200px 600px at 20% -10%, #1b1e2e 0%, var(--bg) 60%);
|
||||
color: var(--text);
|
||||
font: 15px/1.5 "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-elev-2);
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, transform 0.05s ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #262a3c;
|
||||
border-color: #3a3f56;
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button.primary:hover:not(:disabled) {
|
||||
background: #8b7dff;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
button.icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
button.icon.big {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
button.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
color: #cfc7ff;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--bg-elev-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: 2px solid var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 18px 60px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 4px 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.01em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.brand .dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 14px var(--accent);
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.topbar select {
|
||||
width: auto;
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
.who {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.layout > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
/* The search column fills the available height so results have room to breathe. */
|
||||
.card.grow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 460px;
|
||||
}
|
||||
|
||||
.card.grow .track-list {
|
||||
flex: 1;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.card.grow .empty {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.now {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
background: var(--bg-elev-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cover.placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 30px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.now-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.now-title {
|
||||
font-size: 19px;
|
||||
font-weight: 650;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.now-title a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.now-title a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.now-sub {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 8px;
|
||||
border-radius: 99px;
|
||||
background: var(--bg-elev-2);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar > span {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: linear-gradient(90deg, var(--accent), #a78bfa);
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
.times {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
min-width: 170px;
|
||||
}
|
||||
|
||||
.volume input[type="range"] {
|
||||
width: 110px;
|
||||
padding: 0;
|
||||
accent-color: var(--accent);
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
font-size: 12px;
|
||||
padding: 4px 9px;
|
||||
border-radius: 99px;
|
||||
background: var(--bg-elev-2);
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.status-pill.playing {
|
||||
color: var(--ok);
|
||||
border-color: rgba(62, 207, 142, 0.4);
|
||||
}
|
||||
|
||||
.track-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.track:hover {
|
||||
background: var(--bg-elev-2);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.track .idx {
|
||||
width: 22px;
|
||||
text-align: right;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.track .thumb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
background: var(--bg-elev-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.track .info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.track .title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track .sub {
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track .actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.track:hover .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.track .actions button {
|
||||
padding: 5px 9px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-elev-2);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
padding: 26px 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(242, 85, 90, 0.12);
|
||||
border: 1px solid rgba(242, 85, 90, 0.4);
|
||||
color: #ffb4b6;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-wrap {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.login h1 {
|
||||
font-size: 22px;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.login p.sub {
|
||||
color: var(--muted);
|
||||
margin: 0 0 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: 12px 0 6px;
|
||||
}
|
||||
|
||||
.login button {
|
||||
width: 100%;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.voice-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.voice-row select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.voice-status {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--muted);
|
||||
font-size: 13.5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.voice-status strong {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
+54
-54
@@ -1,54 +1,54 @@
|
||||
export type SourceKind = "youtube" | "soundcloud" | "direct" | "local";
|
||||
export type LoopMode = "off" | "track" | "queue";
|
||||
export type PlayerStatus = "idle" | "connecting" | "buffering" | "playing" | "paused";
|
||||
|
||||
export interface Track {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string | null;
|
||||
duration: number;
|
||||
isLive: boolean;
|
||||
url: string;
|
||||
thumbnail: string | null;
|
||||
source: SourceKind;
|
||||
requestedBy: { id: string; username: string };
|
||||
}
|
||||
|
||||
export interface PlayerState {
|
||||
serverId: string;
|
||||
serverName: string | null;
|
||||
voiceChannelId: string | null;
|
||||
voiceChannelName: string | null;
|
||||
status: PlayerStatus;
|
||||
current: Track | null;
|
||||
position: number;
|
||||
queue: Track[];
|
||||
history: Track[];
|
||||
volume: number;
|
||||
loop: LoopMode;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface VoiceChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ServerRef {
|
||||
id: string;
|
||||
name: string;
|
||||
iconUrl: string | null;
|
||||
}
|
||||
|
||||
export interface Me {
|
||||
user: { id: string; username: string };
|
||||
servers: ServerRef[];
|
||||
features: { localLibrary: boolean };
|
||||
}
|
||||
|
||||
export interface ServerStateResponse {
|
||||
state: PlayerState;
|
||||
voiceChannels: VoiceChannel[];
|
||||
yourVoiceChannel: VoiceChannel | null;
|
||||
canControl: boolean;
|
||||
}
|
||||
export type SourceKind = "youtube" | "soundcloud" | "direct" | "local";
|
||||
export type LoopMode = "off" | "track" | "queue";
|
||||
export type PlayerStatus = "idle" | "connecting" | "buffering" | "playing" | "paused";
|
||||
|
||||
export interface Track {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string | null;
|
||||
duration: number;
|
||||
isLive: boolean;
|
||||
url: string;
|
||||
thumbnail: string | null;
|
||||
source: SourceKind;
|
||||
requestedBy: { id: string; username: string };
|
||||
}
|
||||
|
||||
export interface PlayerState {
|
||||
serverId: string;
|
||||
serverName: string | null;
|
||||
voiceChannelId: string | null;
|
||||
voiceChannelName: string | null;
|
||||
status: PlayerStatus;
|
||||
current: Track | null;
|
||||
position: number;
|
||||
queue: Track[];
|
||||
history: Track[];
|
||||
volume: number;
|
||||
loop: LoopMode;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface VoiceChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ServerRef {
|
||||
id: string;
|
||||
name: string;
|
||||
iconUrl: string | null;
|
||||
}
|
||||
|
||||
export interface Me {
|
||||
user: { id: string; username: string };
|
||||
servers: ServerRef[];
|
||||
features: { localLibrary: boolean; requireListener: boolean };
|
||||
}
|
||||
|
||||
export interface ServerStateResponse {
|
||||
state: PlayerState;
|
||||
voiceChannels: VoiceChannel[];
|
||||
yourVoiceChannel: VoiceChannel | null;
|
||||
canControl: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user