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
+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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user