Files
party-mix-app/apps/web/src/app/remote/[id]/page.tsx

418 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<RemoteState | null>(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<Record<string, { title: string; artist: string; duration: string }>>({})
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<ReturnType<typeof setTimeout> | 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 (
<div className="min-h-screen flex flex-col items-center justify-center text-center px-6 gap-3">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#333" strokeWidth="1.5">
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
</svg>
<p className="text-muted text-[13px]">Сессия не найдена или истекла</p>
</div>
)
if (!state) return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-5 h-5 rounded-full border-2 border-surface2 border-t-accent animate-spin" />
</div>
)
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 (
<main className="min-h-screen flex flex-col max-w-sm mx-auto px-4 pt-5 pb-6">
{/* Header + tabs */}
<div className="flex items-center justify-between mb-5">
<span className="text-[11px] font-display font-bold tracking-[1.5px] uppercase text-muted">Party Mix · Пульт</span>
<div className="flex items-center gap-1 bg-surface2 rounded-[8px] p-0.5">
<button
onClick={() => setTab('player')}
className="px-3 py-1 text-[11px] font-display font-bold rounded-[6px] transition-all cursor-pointer"
style={{ background: tab === 'player' ? 'rgba(200,255,0,0.1)' : 'transparent', color: tab === 'player' ? '#c8ff00' : '#555' }}
>
Плеер
</button>
<button
onClick={() => setTab('queue')}
className="px-3 py-1 text-[11px] font-display font-bold rounded-[6px] transition-all cursor-pointer flex items-center gap-1"
style={{ background: tab === 'queue' ? 'rgba(200,255,0,0.1)' : 'transparent', color: tab === 'queue' ? '#c8ff00' : '#555' }}
>
Очередь
{queue.length > 0 && <span className="text-[10px] opacity-60">{queue.length}</span>}
</button>
</div>
</div>
{tab === 'player' && (
<div className="flex flex-col gap-7 items-center flex-1">
{/* Cover */}
<div className="w-full aspect-square max-w-[220px] rounded-[20px] overflow-hidden bg-surface2 shadow-2xl">
{state.cover ? (
<img src={state.cover} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#2a2a2a" strokeWidth="1">
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
</svg>
</div>
)}
</div>
{/* Track info */}
<div className="w-full text-center px-2">
<div className="font-display text-[18px] font-extrabold tracking-tight truncate leading-tight">
{state.title || '—'}
</div>
{state.artist && (
<div className="text-[13px] text-muted mt-1.5 truncate">{state.artist}</div>
)}
{state.queue_len > 0 && (
<div className="text-[11px] text-muted mt-1 font-display">{state.cur_idx + 1} / {state.queue_len}</div>
)}
</div>
{/* Progress bar */}
<div className="w-full">
<div
className="relative h-1.5 bg-white/[0.07] rounded-full mb-2 cursor-pointer"
onClick={(e) => {
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)
}}
>
<div className="absolute inset-y-0 left-0 bg-accent rounded-full" style={{ width: `${progress}%` }} />
</div>
<div className="flex justify-between text-[11px] text-muted font-display tabular-nums">
<span>{formatTime(clampedProgress)}</span>
<span>{formatTime(state.duration)}</span>
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-8">
<button
onClick={() => cmd(id, 'prev')}
className="w-12 h-12 rounded-full flex items-center justify-center text-muted hover:text-app-text active:scale-95 transition-all cursor-pointer"
>
<svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" />
</svg>
</button>
<button
onClick={() => cmd(id, state.is_playing ? 'pause' : 'play')}
className="w-16 h-16 rounded-full flex items-center justify-center bg-accent text-bg active:scale-95 transition-all cursor-pointer hover:brightness-110"
>
{state.is_playing ? (
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" />
</svg>
) : (
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
<button
onClick={() => cmd(id, 'next')}
className="w-12 h-12 rounded-full flex items-center justify-center text-muted hover:text-app-text active:scale-95 transition-all cursor-pointer"
>
<svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 18l8.5-6L6 6v12z"/><rect x="16" y="6" width="2" height="12"/>
</svg>
</button>
</div>
{/* Volume */}
<div className="w-full flex items-center gap-3">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted shrink-0">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
</svg>
<input
type="range"
min="0"
max="1"
step="0.02"
value={volume}
onChange={(e) => {
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' }}
/>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted shrink-0">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14" />
</svg>
</div>
{/* Versions */}
{versions.length > 1 && (
<div className="w-full">
<button
onClick={() => setVersionsOpen(v => !v)}
className="w-full flex items-center justify-between px-3 py-2 rounded-[9px] bg-surface2 border border-white/[0.07] text-[12px] text-muted hover:text-app-text transition-colors cursor-pointer"
>
<span className="font-display font-bold tracking-[0.5px]">Версии трека</span>
<span className="flex items-center gap-1.5">
<span className="text-[11px] opacity-60">{versions.length}</span>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className={`transition-transform ${versionsOpen ? 'rotate-180' : ''}`}>
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
{versionsOpen && (
<div className="mt-1 border border-white/[0.07] rounded-[9px] overflow-hidden">
{versions.map((v, i) => {
const active = i === state.active_version
const saved = isSavedLocally(v)
return (
<div
key={i}
className="flex items-center gap-2.5 px-3 py-2.5 border-b border-white/[0.05] last:border-b-0 transition-colors"
style={{ background: active ? 'rgba(200,255,0,0.04)' : undefined }}
>
<span className="text-[11px] text-muted w-4 text-right shrink-0 font-display">{i + 1}</span>
{v.img ? (
<img src={v.img} alt="" className="w-8 h-8 rounded-[5px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
) : (
<div className="w-8 h-8 rounded-[5px] bg-surface2 shrink-0" />
)}
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => { cmd(id, 'version', i); setVersionsOpen(false) }}>
<div className="text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis">{v.title}</div>
<div className="text-[11px] text-muted mt-px">{v.artist}</div>
</div>
<span className="text-[11px] text-muted shrink-0 font-display">{v.duration}</span>
<button
onClick={(e) => { e.stopPropagation(); handleSaveVersion(i) }}
title={saved ? 'Забыть версию' : 'Запомнить версию'}
className="w-7 h-7 rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer"
style={{ borderColor: saved ? 'rgba(200,255,0,0.4)' : 'rgba(255,255,255,0.07)', background: saved ? 'rgba(200,255,0,0.08)' : 'transparent' }}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill={saved ? '#c8ff00' : 'none'} stroke={saved ? '#c8ff00' : '#555'} strokeWidth="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</button>
<button
onClick={() => { cmd(id, 'version', i); setVersionsOpen(false) }}
className={`rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:bg-accent hover:border-accent ${active ? 'bg-accent border-accent' : 'border-white/[0.07]'}`}
style={{ width: 26, height: 26 }}
>
<svg width="9" height="9" viewBox="0 0 24 24" fill={active ? '#0a0a0f' : '#555'}><path d="M8 5v14l11-7z" /></svg>
</button>
</div>
)
})}
</div>
)}
</div>
)}
</div>
)}
{tab === 'queue' && (
<div className="flex flex-col gap-3 flex-1">
{/* Add track */}
<div className="flex gap-2">
<input
type="text"
value={addText}
onChange={(e) => 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"
/>
<button
onClick={() => {
if (!addText.trim()) return
cmd(id, 'add', 0, addText.trim())
setAddText('')
}}
className="shrink-0 px-3 py-2.5 rounded-[9px] bg-accent text-bg text-[13px] font-display font-bold cursor-pointer hover:brightness-110 active:scale-95 transition-all"
>
+
</button>
</div>
{/* Queue list */}
{queue.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-[13px] text-muted text-center py-10">
Очередь пуста
</div>
) : (
<div className="flex flex-col">
{queue.map((item, i) => {
const active = i === state.cur_idx
return (
<div
key={i}
onClick={() => 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 ? (
<div className="flex items-end gap-[1.5px] w-3.5 h-3.5 shrink-0 ml-0.5">
<div className="queue-bar" /><div className="queue-bar" /><div className="queue-bar" />
</div>
) : (
<span className="text-[11px] text-muted w-5 text-right shrink-0 font-display">{i + 1}</span>
)}
{item.img ? (
<img src={item.img} alt="" className="w-8 h-8 rounded-[6px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
) : (
<div className="w-8 h-8 rounded-[6px] bg-surface2 shrink-0" />
)}
<span className="flex-1 text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis">{item.title}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded-[5px] shrink-0 font-medium" style={{ background: item.color_bg, color: item.color_text }}>
{item.owner}
</span>
<button
onClick={(e) => { e.stopPropagation(); cmd(id, 'remove', i) }}
className="w-7 h-7 rounded-full flex items-center justify-center text-muted hover:text-[#ff6b6b] hover:bg-[rgba(255,107,107,0.08)] transition-all cursor-pointer shrink-0"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
)
})}
</div>
)}
</div>
)}
</main>
)
}