'use client' import { use, useEffect, useRef, useState } from 'react' const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080' interface RemoteQueueItem { title: string owner: string color_bg: string color_text: string img?: string } interface RemoteVersion { title: string artist: string duration: string img?: string } interface RemoteState { title: string artist: string cover: string is_playing: boolean volume: number progress: number duration: number queue_len: number cur_idx: number queue: RemoteQueueItem[] versions: RemoteVersion[] active_version: number } function formatTime(s: number) { if (!s || isNaN(s)) return '0:00' return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, '0')}` } async function cmd(id: string, c: string, value?: number, text?: string) { await fetch(`${API_URL}/api/remote/${id}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cmd: c, value: value ?? 0, text }), }).catch(() => {}) } export default function RemotePage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params) const [state, setState] = useState(null) const [notFound, setNotFound] = useState(false) const [volume, setVolume] = useState(1) const [addText, setAddText] = useState('') const [tab, setTab] = useState<'player' | 'queue'>('player') const [versionsOpen, setVersionsOpen] = useState(false) const [savedVersions, setSavedVersions] = useState>({}) const [localProgress, setLocalProgress] = useState(0) const lastPollRef = useRef<{ progress: number; ts: number; playing: boolean }>({ progress: 0, ts: Date.now(), playing: false }) useEffect(() => { try { const s = typeof window !== 'undefined' ? localStorage.getItem('pm_versions') : null if (s) setSavedVersions(JSON.parse(s)) } catch {} }, []) const volumeDebounce = useRef | null>(null) useEffect(() => { let active = true const poll = async () => { try { const res = await fetch(`${API_URL}/api/remote/${id}/state`) if (res.status === 404) { setNotFound(true); return } if (!res.ok) return const data: RemoteState = await res.json() if (active) { setState(data) lastPollRef.current = { progress: data.progress ?? 0, ts: Date.now(), playing: data.is_playing } setLocalProgress(data.progress ?? 0) setVolume(v => Math.abs(v - (data.volume ?? 1)) > 0.05 ? (data.volume ?? 1) : v) } } catch {} } poll() const iv = setInterval(poll, 2000) return () => { active = false; clearInterval(iv) } }, [id]) // Local progress interpolation — advance every 250ms when playing useEffect(() => { const iv = setInterval(() => { const { progress, ts, playing } = lastPollRef.current if (playing) setLocalProgress(progress + (Date.now() - ts) / 1000) }, 250) return () => clearInterval(iv) }, []) if (notFound) return (

Сессия не найдена или истекла

) if (!state) return (
) const clampedProgress = Math.min(localProgress, state.duration || localProgress) const progress = state.duration > 0 ? (clampedProgress / state.duration) * 100 : 0 const queue = state.queue ?? [] const versions = state.versions ?? [] const trackTitle = queue[state.cur_idx]?.title ?? '' const isSavedLocally = (v: RemoteVersion) => { const s = savedVersions[trackTitle] return !!s && s.title === v.title && s.artist === v.artist && s.duration === v.duration } const handleSaveVersion = (i: number) => { const v = versions[i] if (!v || !trackTitle) return const alreadySaved = isSavedLocally(v) const next = { ...savedVersions } if (alreadySaved) delete next[trackTitle] else next[trackTitle] = { title: v.title, artist: v.artist, duration: v.duration } setSavedVersions(next) try { localStorage.setItem('pm_versions', JSON.stringify(next)) } catch {} cmd(id, 'save_version', i) } return (
{/* Header + tabs */}
Party Mix · Пульт
{tab === 'player' && (
{/* Cover */}
{state.cover ? ( ) : (
)}
{/* Track info */}
{state.title || '—'}
{state.artist && (
{state.artist}
)} {state.queue_len > 0 && (
{state.cur_idx + 1} / {state.queue_len}
)}
{/* Progress bar */}
{ const rect = e.currentTarget.getBoundingClientRect() const pct = (e.clientX - rect.left) / rect.width const seekTo = pct * state.duration lastPollRef.current = { progress: seekTo, ts: Date.now(), playing: state.is_playing } setLocalProgress(seekTo) cmd(id, 'seek', seekTo) }} >
{formatTime(clampedProgress)} {formatTime(state.duration)}
{/* Controls */}
{/* Volume */}
{ const v = parseFloat(e.target.value) setVolume(v) if (volumeDebounce.current) clearTimeout(volumeDebounce.current) volumeDebounce.current = setTimeout(() => cmd(id, 'volume', v), 150) }} className="flex-1 cursor-pointer h-1.5" style={{ accentColor: '#c8ff00' }} />
{/* Versions */} {versions.length > 1 && (
{versionsOpen && (
{versions.map((v, i) => { const active = i === state.active_version const saved = isSavedLocally(v) return (
{i + 1} {v.img ? ( ((e.target as HTMLImageElement).style.display = 'none')} /> ) : (
)}
{ cmd(id, 'version', i); setVersionsOpen(false) }}>
{v.title}
{v.artist}
{v.duration}
) })}
)}
)}
)} {tab === 'queue' && (
{/* Add track */}
setAddText(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && addText.trim()) { cmd(id, 'add', 0, addText.trim()) setAddText('') } }} placeholder="Исполнитель — Название" className="flex-1 min-w-0 text-[13px] bg-surface2 border border-white/[0.07] rounded-[9px] px-3 py-2.5 text-app-text outline-none focus:border-accent/35 placeholder:text-muted transition-colors" />
{/* Queue list */} {queue.length === 0 ? (
Очередь пуста
) : (
{queue.map((item, i) => { const active = i === state.cur_idx return (
cmd(id, 'goto', i)} className="flex items-center gap-2.5 px-1 py-2.5 border-b border-white/[0.05] last:border-b-0 cursor-pointer active:bg-surface2 transition-colors rounded-[6px]" style={{ background: active ? 'rgba(200,255,0,0.04)' : undefined }} > {active ? (
) : ( {i + 1} )} {item.img ? ( ((e.target as HTMLImageElement).style.display = 'none')} /> ) : (
)} {item.title} {item.owner}
) })}
)}
)}
) }