'use client' import { use, useEffect, useRef, useState, useCallback } 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 [connected, setConnected] = useState(true) const [seeking, setSeeking] = useState(false) const lastPollRef = useRef<{ progress: number; ts: number; playing: boolean }>({ progress: 0, ts: Date.now(), playing: false }) const lastSuccessRef = useRef(Date.now()) const progressBarRef = useRef(null) const volumeDebounce = useRef | null>(null) useEffect(() => { try { const s = typeof window !== 'undefined' ? localStorage.getItem('pm_versions') : null if (s) setSavedVersions(JSON.parse(s)) } catch {} }, []) 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) if (!seeking) { 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) lastSuccessRef.current = Date.now() setConnected(true) } } catch { if (active) setConnected(false) } } poll() const iv = setInterval(poll, 2000) return () => { active = false; clearInterval(iv) } }, [id, seeking]) useEffect(() => { const iv = setInterval(() => { if (seeking) return const { progress, ts, playing } = lastPollRef.current if (playing) setLocalProgress(progress + (Date.now() - ts) / 1000) }, 250) return () => clearInterval(iv) }, [seeking]) const getSeekPct = useCallback((clientX: number) => { const bar = progressBarRef.current if (!bar || !state?.duration) return null const rect = bar.getBoundingClientRect() return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) }, [state?.duration]) const handleSeekStart = useCallback((clientX: number) => { const pct = getSeekPct(clientX) if (pct === null) return setSeeking(true) const seekTo = pct * (state?.duration ?? 0) setLocalProgress(seekTo) }, [getSeekPct, state?.duration]) const handleSeekMove = useCallback((clientX: number) => { if (!seeking) return const pct = getSeekPct(clientX) if (pct === null) return setLocalProgress(pct * (state?.duration ?? 0)) }, [seeking, getSeekPct, state?.duration]) const handleSeekEnd = useCallback((clientX: number) => { if (!seeking) return const pct = getSeekPct(clientX) if (pct !== null && state) { const seekTo = pct * state.duration lastPollRef.current = { progress: seekTo, ts: Date.now(), playing: state.is_playing } setLocalProgress(seekTo) cmd(id, 'seek', seekTo) } setSeeking(false) }, [seeking, getSeekPct, state, id]) useEffect(() => { const onMouseMove = (e: MouseEvent) => handleSeekMove(e.clientX) const onMouseUp = (e: MouseEvent) => handleSeekEnd(e.clientX) const onTouchMove = (e: TouchEvent) => { if (e.touches[0]) handleSeekMove(e.touches[0].clientX) } const onTouchEnd = (e: TouchEvent) => { const t = e.changedTouches[0]; if (t) handleSeekEnd(t.clientX) } if (seeking) { window.addEventListener('mousemove', onMouseMove) window.addEventListener('mouseup', onMouseUp) window.addEventListener('touchmove', onTouchMove, { passive: true }) window.addEventListener('touchend', onTouchEnd) } return () => { window.removeEventListener('mousemove', onMouseMove) window.removeEventListener('mouseup', onMouseUp) window.removeEventListener('touchmove', onTouchMove) window.removeEventListener('touchend', onTouchEnd) } }, [seeking, handleSeekMove, handleSeekEnd]) if (notFound) return (

Сессия не найдена

Ссылка устарела или сессия завершена

) if (!state) return (
) const clampedProgress = Math.min(localProgress, state.duration || localProgress) const progressPct = state.duration > 0 ? (clampedProgress / state.duration) * 100 : 0 const queue = state.queue ?? [] const versions = state.versions ?? [] const trackTitle = queue[state.cur_idx]?.title ?? '' const currentOwner = queue[state.cur_idx] 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 */}
Party Mix · Пульт
{(['player', 'queue'] as const).map(t => ( ))}
{tab === 'player' && (
{/* Cover */}
{state.cover ? ( ) : (
)}
{/* Track info */}
{state.title || '—'}
{state.artist && (
{state.artist}
)}
{currentOwner && ( {currentOwner.owner} )} {state.queue_len > 0 && ( {state.cur_idx + 1} / {state.queue_len} )}
{/* Progress bar */}
{ e.preventDefault(); handleSeekStart(e.clientX) }} onTouchStart={(e) => { if (e.touches[0]) handleSeekStart(e.touches[0].clientX) }} >
{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: 'var(--accent)' }} />
{/* 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-[10px] 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-2 py-2.5 border-b border-white/[0.05] last:border-b-0 cursor-pointer rounded-[8px] transition-colors active:bg-surface2" style={{ background: active ? 'rgba(var(--accent-rgb),0.05)' : undefined }} > {/* Index / playing indicator */}
{active ? (
) : ( {i + 1} )}
{/* Cover */} {item.img ? ( ((e.target as HTMLImageElement).style.display = 'none')} /> ) : (
)} {/* Title + owner */}
{item.title} {item.owner}
{/* Remove */}
) })}
)}
)}
) }