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>
142 lines
5.1 KiB
TypeScript
142 lines
5.1 KiB
TypeScript
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;
|
||
}
|
||
|
||
const SOURCE_KEY = "mbot.searchSource";
|
||
|
||
export function SearchPanel({ serverId, canControl, localLibrary, onError }: Props) {
|
||
const [query, setQuery] = useState("");
|
||
const [source, setSource] = useState<string>(() => localStorage.getItem(SOURCE_KEY) ?? "all");
|
||
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, source);
|
||
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}>
|
||
<select
|
||
value={source}
|
||
onChange={(event) => {
|
||
setSource(event.target.value);
|
||
localStorage.setItem(SOURCE_KEY, event.target.value);
|
||
}}
|
||
disabled={!canControl}
|
||
title="Где искать"
|
||
>
|
||
<option value="all">Везде</option>
|
||
<option value="youtube">YouTube</option>
|
||
<option value="soundcloud">SoundCloud</option>
|
||
{localLibrary && <option value="local">Медиатека</option>}
|
||
</select>
|
||
<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}» ничего не нашлось. YouTube иногда прячет результаты от ботов — попробуйте другие слова или вставьте прямую ссылку на трек.`
|
||
: 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>
|
||
);
|
||
}
|