Search now queries YouTube and SoundCloud together by default and interleaves the two result lists so neither buries the other; a picker left of the input narrows it to one source (plus the local library when configured), and a prefix typed into the query still outranks it. The panel was capped at 1180px, which left most of a wide screen empty — it now scales to 1680px and gives the search column the extra room. A dead link also reported "could not parse yt-dlp's response", which described our parser rather than the problem; it now shows yt-dlp's own error line, or says the link did not open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
94 lines
3.4 KiB
TypeScript
94 lines
3.4 KiB
TypeScript
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, source: string) =>
|
|
request<{ tracks: Track[] }>(
|
|
`/api/servers/${serverId}/search?q=${encodeURIComponent(query)}&source=${source}`,
|
|
),
|
|
|
|
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);
|
|
}
|