diff --git a/CLAUDE.md b/CLAUDE.md index c9ab9cc..2249fbd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,6 +98,27 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone forever and Chrome killed the tab ("Target crashed" in Playwright, no JS error). Never cap with a loop whose exit depends on a deferred removal. +## Phone remote (`server/remote.js`, `Remote` in app.js) +- Desktop tab playing to a TV = **host**; phone = **remote**. Both are browser + tabs of this app, so the server relays over `/ws/remote` (Bun `server.upgrade` + in the `Bun.serve` fetch wrapper, before Hono). Mockup: + `docs/mockups/mock-06-remote-control.html` (it assumed a LAN host + mDNS; the web + app can't do that, so it's a server relay + a 6-digit code instead). +- Host secret lives in the desktop's localStorage (`ytpRemoteHost`); room id = + hash(secret). Pairing (`POST /api/remote/pair`) is a one-time 6-digit code + (10 min) → phone gets `token = HMAC(secret, remoteId)` (`ytpRemotePair`). + **Nothing about phones is stored server-side**: tokens are re-checked against + the connected host's secret, so server restarts / desktop reloads keep phones + paired, and "Unpair all" (new secret) revokes every phone at once. +- The host pushes `state` (≤1/s, deduped) and `queue` (`{items, idx}` of the live + `queue`/`queueIndex`); commands are whitelisted server-side (`REMOTE_COMMANDS`) + and run by `runCommand()` on the host with the same functions the UI uses. +- `REMOTE_SAME_NETWORK=1` = pairing requires the phone and desktop to share a + public IP (first `X-Forwarded-For` hop). Off by default — verify that the + VPS→homelab Traefik chain forwards the real client IP before turning it on. +- QR: `GET /api/remote/qr/:code` (server-side SVG via `qrcode`) encodes + `/?pair=`; the app consumes and strips `?pair=` at boot. + ## Local dev ```bash diff --git a/docker-compose.yml b/docker-compose.yml index 99a6cbf..f8d2c28 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,9 @@ services: # Unlocks /admin (API tokens, lyric/chapter history + restore). Set it in # Dokploy's Environment tab — never commit it. Unset = admin disabled. ADMIN_PASSWORD: "${ADMIN_PASSWORD:-}" + # Phone remote: "1" = a phone may only pair with a screen on the same + # network (same public IP as seen by the server). Off by default. + REMOTE_SAME_NETWORK: "${REMOTE_SAME_NETWORK:-0}" # Optional: override yt-dlp binary path if you mount a custom one # YTDLP_PATH: "/usr/local/bin/yt-dlp" # Server media cache (server/media-cache.js) — defaults shown. diff --git a/frontend/app.js b/frontend/app.js index 8a8e3e4..6a7446d 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -1630,6 +1630,7 @@ const Player = { afterLoad() { addToHistory(current.meta); Notes.load(current.meta); + Remote.changed(); buildQualityMenu(); els.playerPane.classList.remove('empty'); els.controls.classList.remove('hidden'); @@ -2191,6 +2192,7 @@ function wirePlayerEvents() { } function updatePlayBtn() { + Remote.changed(); els.playBtn.textContent = Player.master.paused ? '▶' : '⏸'; $('miniPlayBtn').textContent = Player.master.paused ? '▶' : '⏸'; const spBtn = $('servicePlayBtn'); @@ -2985,6 +2987,658 @@ const Notes = (() => { return { load, tick, addBookmark, handleSyncKey }; })(); +// ============================================================================ +// Phone remote (server/remote.js relays both ends over /ws/remote) +// ============================================================================ +// Host = this tab plays to the TV / speakers and obeys a paired phone. +// Remote = this phone drives a host. Hosting survives reloads (the secret and +// "enabled" flag live in localStorage), so a TV PC that restarts its browser +// keeps its phones paired. +const Remote = (() => { + const noop = { boot() {}, changed() {} }; + if (!WEB || typeof WebSocket === 'undefined') return noop; + + const HOST_KEY = 'ytpRemoteHost'; + const PAIR_KEY = 'ytpRemotePair'; + const load = (k) => { try { return JSON.parse(localStorage.getItem(k) || 'null'); } catch { return null; } }; + const save = (k, v) => { try { if (v) localStorage.setItem(k, JSON.stringify(v)); else localStorage.removeItem(k); } catch { /* storage blocked */ } }; + const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/remote?${new URLSearchParams(q)}`; + const newSecret = () => { + const b = new Uint8Array(32); + crypto.getRandomValues(b); + return btoa(String.fromCharCode(...b)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + }; + const guessName = (fallback) => { + const ua = navigator.userAgent || ''; + if (/iPhone/.test(ua)) return 'iPhone'; + if (/iPad/.test(ua)) return 'iPad'; + if (/Android/.test(ua)) return 'Android phone'; + if (/Windows/.test(ua)) return 'Windows PC'; + if (/Mac/.test(ua)) return 'Mac'; + return fallback; + }; + const clock = (t) => (window.LyricsCore ? LyricsCore.fmtClock(t) : fmtTime(t)); + + // ========================================================================== + // Host (the screen being controlled) + // ========================================================================== + const host = { ws: null, remotes: [], code: '', codeExpires: 0, retryMs: 2000, timer: null, lastState: '', lastQueue: '', replaced: false, pending: false }; + + function hostCfg() { + let cfg = load(HOST_KEY); + if (!cfg || !cfg.secret) cfg = { secret: newSecret(), name: guessName('Desktop'), enabled: false }; + return cfg; + } + + function hostConnect() { + const cfg = hostCfg(); + if (!cfg.enabled) return; + try { if (host.ws) host.ws.close(); } catch { /* already closed */ } + const ws = new WebSocket(wsUrl({ role: 'host', secret: cfg.secret, name: cfg.name })); + host.ws = ws; + host.replaced = false; + ws.onopen = () => { host.retryMs = 2000; }; + ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onHostMessage(m); }; + ws.onclose = (e) => { + if (host.ws !== ws) return; + host.ws = null; + host.remotes = []; + renderChip(); + renderHostModal(); + if (e.code === 4000) { host.replaced = true; renderHostModal(); return; } // another tab hosts now + if (hostCfg().enabled) { + clearTimeout(host.timer); + host.timer = setTimeout(hostConnect, host.retryMs); + host.retryMs = Math.min(host.retryMs * 2, 30000); + } + }; + } + + const hostSend = (m) => { if (host.ws && host.ws.readyState === 1) host.ws.send(JSON.stringify(m)); }; + + function onHostMessage(m) { + if (m.type === 'hello') { + host.code = m.code; host.codeExpires = m.codeExpires; host.remotes = m.remotes || []; + host.lastState = ''; host.lastQueue = ''; + pushState(); pushQueue(); + } else if (m.type === 'code') { + host.code = m.code; host.codeExpires = m.codeExpires; + } else if (m.type === 'remotes') { + host.remotes = m.remotes || []; + if (m.joined) { toast(`📱 ${m.joined} connected as a remote`); host.lastState = ''; host.lastQueue = ''; pushState(); pushQueue(); } + if (m.left) toast(`📱 ${m.left} disconnected`); + } else if (m.type === 'ping') { + hostSend({ type: 'pong', rid: m.rid, t: m.t }); + return; + } else if (m.type === 'cmd') { + runCommand(m); + return; + } + renderChip(); + renderHostModal(); + } + + function stateNow() { + const meta = current && current.meta; + const M = Player.master; + return { + v: meta ? { id: meta.id, title: meta.title || '', channel: meta.channel || '', thumbnail: meta.thumbnail || '' } : null, + cur: M ? Math.floor((M.currentTime || 0) * 10) / 10 : 0, + dur: (M && isFinite(M.duration) && M.duration) || (meta && meta.duration) || 0, + paused: !M || M.paused, + volume: data.settings.volume ?? 1, + service: isServiceModeOpen(), + sleep: sleepTimerRemaining || 0, + hostName: hostCfg().name, + }; + } + + function pushState(force = false) { + if (!host.ws || !host.remotes.length) return; + const st = stateNow(); + const key = JSON.stringify({ ...st, cur: Math.floor(st.cur) }); + if (!force && key === host.lastState) return; + host.lastState = key; + hostSend({ type: 'state', state: { ...st, at: Date.now() } }); + } + + function pushQueue() { + if (!host.ws || !host.remotes.length) return; + const items = queue.slice(0, 200).map((v) => ({ + id: v.id, title: v.title || '', channel: v.channel || '', duration: v.duration || 0, thumbnail: v.thumbnail || '', + })); + const key = queueIndex + '|' + items.map((v) => v.id).join(','); + if (key === host.lastQueue) return; + host.lastQueue = key; + hostSend({ type: 'queue', queue: { items, idx: queueIndex } }); + } + + // Coalesce bursts (play event + renderUpNext + afterLoad) into one push. + function changed() { + if (!host.ws || host.pending) return; + host.pending = true; + setTimeout(() => { host.pending = false; pushState(); pushQueue(); }, 50); + } + + const validIdx = (i) => Number.isInteger(i) && i > queueIndex && i < queue.length; + + function runCommand(m) { + const who = m.from || 'Phone'; + switch (m.cmd) { + case 'toggle': Player.toggle(); break; + case 'play': Player.play(); break; + case 'pause': Player.pause(); break; + case 'next': playNext(); break; + case 'prev': playPrev(); break; + case 'seek': { + const t = Number(m.t); + if (isFinite(t) && t >= 0) Player.seek(t); + break; + } + case 'volume': { + const v = Math.max(0, Math.min(1, Number(m.v))); + if (!isFinite(v)) break; + data.settings.volume = v; + els.volume.value = String(v); + Player.applyVolume(); + els.muteBtn.textContent = v === 0 ? '🔇' : '🔊'; + persist(); + break; + } + case 'jump': { + const i = Number(m.i); + if (Number.isInteger(i) && i >= 0 && i < queue.length) { queueIndex = i - 1; playNext(); } + break; + } + case 'remove': { + const i = Number(m.i); + if (validIdx(i)) { queue.splice(i, 1); renderUpNext(); } + break; + } + case 'move': { + const from = Number(m.from), to = Number(m.to); + if (validIdx(from) && validIdx(to) && from !== to) { + const [v] = queue.splice(from, 1); + queue.splice(to, 0, v); + renderUpNext(); + } + break; + } + case 'add': { + const v = m.video; + if (!v || typeof v.id !== 'string' || !/^[A-Za-z0-9_-]{11}$/.test(v.id)) break; + const item = slim({ ...v, title: String(v.title || v.id).slice(0, 300), channel: String(v.channel || '').slice(0, 200) }); + if (!current || !queue.length) { playFromList([item], 0, 'remote'); } + else if (!queue.slice(queueIndex + 1).some((x) => x.id === item.id)) { queue.push(item); renderUpNext(); } + toast(`📱 ${who} added “${item.title}”`); + break; + } + case 'sleep': { + const min = Number(m.min) || 0; + if (min > 0) startSleepTimer(Math.min(min, 240)); else cancelSleepTimer(); + break; + } + case 'service': + if (m.on) enterServiceMode(); else exitServiceMode(); + break; + default: return; + } + changed(); + } + + function startHosting() { + const cfg = hostCfg(); + cfg.enabled = true; + save(HOST_KEY, cfg); + hostConnect(); + renderHostModal(); + } + + function stopHosting() { + const cfg = hostCfg(); + cfg.enabled = false; + save(HOST_KEY, cfg); + clearTimeout(host.timer); + const ws = host.ws; + host.ws = null; + host.remotes = []; + try { if (ws) ws.close(); } catch { /* gone */ } + renderChip(); + renderHostModal(); + } + + function unpairAll() { + hostSend({ type: 'unpair-all' }); + const cfg = hostCfg(); + cfg.secret = newSecret(); // every phone's token was derived from the old one + save(HOST_KEY, cfg); + setTimeout(hostConnect, 300); + toast('All phones unpaired'); + } + + function renderChip() { + const chip = $('remoteChip'); + if (!chip) return; + const n = host.remotes.length; + chip.classList.toggle('hidden', !host.ws || !n); + if (n) { + $('remoteChipText').textContent = n === 1 + ? `Remote connected — ${host.remotes[0].name}` + : `${n} remotes connected`; + } + } + + let hostModalOpen = false; + function renderHostModal() { + if (!hostModalOpen) return; + const body = $('modalBody') && $('modalBody').querySelector('.remote-host'); + if (!body) { hostModalOpen = false; return; } + const cfg = hostCfg(); + const live = !!host.ws; + const mins = host.codeExpires ? Math.max(0, Math.round((host.codeExpires - Date.now()) / 60000)) : 0; + body.querySelector('.rh-body').innerHTML = !cfg.enabled ? ` + ` + : host.replaced ? `` + : !live ? `` + : ` +
+ QR code to pair a phone +
+
Pairing code
+
${host.code.slice(0, 3)} ${host.code.slice(3)}
+
Valid ${mins} more min · one use
+
On the phone: open this app → 📱 Remote → Use this phone as a remote, or scan the QR code.
+
+
+
Connected phones
+ ${host.remotes.length ? host.remotes.map((r) => ` +
📱 ${escapeHtml(r.name)}
`).join('') + : '
None yet.
'}`; + const nameIn = body.querySelector('.rh-name'); + if (document.activeElement !== nameIn) nameIn.value = cfg.name; + body.querySelector('[data-act="start"]').classList.toggle('hidden', cfg.enabled && !host.replaced); + body.querySelector('[data-act="stop"]').classList.toggle('hidden', !cfg.enabled); + body.querySelector('[data-act="code"]').classList.toggle('hidden', !live); + body.querySelector('[data-act="unpair"]').classList.toggle('hidden', !live); + } + + function openHostModal() { + const body = document.createElement('div'); + body.className = 'remote-host'; + body.innerHTML = ` +
+ +
+ + + + +
`; + body.addEventListener('click', (e) => { + const kick = e.target.closest('[data-kick]'); + if (kick) { hostSend({ type: 'kick', rid: kick.dataset.kick }); return; } + const act = e.target.closest('[data-act]'); + if (!act) return; + if (act.dataset.act === 'start') startHosting(); + else if (act.dataset.act === 'stop') stopHosting(); + else if (act.dataset.act === 'code') hostSend({ type: 'new-code' }); + else if (act.dataset.act === 'unpair') unpairAll(); + }); + body.querySelector('.rh-name').addEventListener('change', (e) => { + const cfg = hostCfg(); + cfg.name = e.target.value.trim().slice(0, 40) || guessName('Desktop'); + save(HOST_KEY, cfg); + if (cfg.enabled) hostConnect(); // the server learns names on connect + }); + showModal('📱 Control this screen from a phone', body, [ + { label: 'Done', primary: true, onClick: () => { hostModalOpen = false; closeModal(); } }, + ]); + hostModalOpen = true; + renderHostModal(); + } + + // ========================================================================== + // Remote (this phone drives a host) + // ========================================================================== + const rc = { ws: null, open: false, state: null, stateAt: 0, queue: { items: [], idx: -1 }, retry: null, ping: null, latency: null, online: false, seeking: false, volTimer: null, drag: null }; + + const rcSend = (m) => { if (rc.ws && rc.ws.readyState === 1) rc.ws.send(JSON.stringify(m)); }; + const cmd = (name, args = {}) => rcSend({ type: 'cmd', cmd: name, ...args }); + + function setStatus(text, ok = false) { + $('rvStatus').textContent = text; + $('rvStatus').classList.toggle('ok', ok); + } + + function showPairForm(msg = '') { + $('rvPair').classList.remove('hidden'); + $('rvControl').classList.add('hidden'); + $('rvHostName').textContent = 'Remote'; + setStatus('Not paired'); + $('rvPairMsg').textContent = msg; + if (!$('rvName').value) $('rvName').value = (load(PAIR_KEY) || {}).name || guessName('Phone'); + } + + function rcConnect() { + const p = load(PAIR_KEY); + if (!p || !rc.open) return; + clearTimeout(rc.retry); + try { if (rc.ws) rc.ws.close(); } catch { /* gone */ } + $('rvPair').classList.add('hidden'); + $('rvControl').classList.remove('hidden'); + $('rvHostName').textContent = p.hostName || 'Screen'; + setStatus('Connecting…'); + const ws = new WebSocket(wsUrl({ role: 'remote', room: p.roomId, rid: p.remoteId, token: p.token, name: p.name || guessName('Phone') })); + rc.ws = ws; + ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onRemoteMessage(m); }; + ws.onclose = (e) => { + if (rc.ws !== ws) return; + rc.ws = null; + rc.online = false; + clearInterval(rc.ping); + if (e.code === 4001) { save(PAIR_KEY, null); showPairForm('This phone was unpaired by the screen — pair again.'); return; } + if (!rc.open) return; + setStatus(e.code === 4004 ? 'Screen offline — retrying…' : 'Reconnecting…'); + rc.retry = setTimeout(rcConnect, 4000); + }; + } + + function onRemoteMessage(m) { + if (m.type === 'hello') { + rc.online = true; + $('rvHostName').textContent = m.hostName || 'Screen'; + if (m.state) { rc.state = m.state; rc.stateAt = Date.now(); } + if (m.queue) rc.queue = m.queue; + setStatus('Synced', true); + clearInterval(rc.ping); + const ping = () => rcSend({ type: 'ping', t: Date.now() }); + ping(); + rc.ping = setInterval(ping, 5000); + renderRemote(); renderQueue(); + } else if (m.type === 'state') { + rc.state = m.state; rc.stateAt = Date.now(); + renderRemote(); + } else if (m.type === 'queue') { + rc.queue = m.queue || { items: [], idx: -1 }; + renderQueue(); + } else if (m.type === 'pong') { + rc.latency = Date.now() - m.t; + if (rc.online) setStatus(`Synced · ${rc.latency} ms`, true); + } else if (m.type === 'host-offline') { + rc.online = false; + setStatus('Screen offline — waiting…'); + } + } + + function liveCur() { + const s = rc.state; + if (!s) return 0; + const cur = s.paused ? s.cur : s.cur + (Date.now() - rc.stateAt) / 1000; + return Math.min(cur, s.dur || cur); + } + + function renderRemote() { + const s = rc.state; + const v = s && s.v; + $('rvTitle').textContent = v ? v.title : 'Nothing playing'; + $('rvChannel').textContent = v ? v.channel : ''; + const thumb = $('rvThumb'); + if (v && v.thumbnail) { if (thumb.getAttribute('src') !== v.thumbnail) thumb.src = v.thumbnail; thumb.classList.remove('hidden'); } + else thumb.classList.add('hidden'); + $('rvToggle').textContent = s && !s.paused ? '⏸' : '▶'; + $('rvDur').textContent = clock((s && s.dur) || 0); + if (!rc.volTimer && document.activeElement !== $('rvVolume')) { + const pct = Math.round(((s && s.volume) ?? 1) * 100); + $('rvVolume').value = String(pct); + $('rvVolVal').textContent = pct + '%'; + } + const sleepMin = s && s.sleep ? Math.ceil(s.sleep / 60) : 0; + $('rvSleep').textContent = sleepMin ? `⏱ ${sleepMin} min` : '⏱ Sleep'; + $('rvSleep').classList.toggle('on', !!sleepMin); + $('rvService').classList.toggle('on', !!(s && s.service)); + renderSeek(); + } + + function renderSeek() { + if (rc.seeking || !rc.open) return; + const s = rc.state; + const cur = liveCur(); + $('rvCur').textContent = clock(cur); + $('rvSeek').value = s && s.dur ? String(Math.round((cur / s.dur) * 1000)) : '0'; + } + + function renderQueue() { + const q = rc.queue || { items: [], idx: -1 }; + const up = q.items.map((v, i) => ({ ...v, i })).filter((v) => v.i > q.idx); + $('rvQueueCount').textContent = up.length ? `${up.length} song${up.length === 1 ? '' : 's'}` : ''; + const list = $('rvQueue'); + list.innerHTML = up.length ? up.map((v) => ` +
+ ⠿ +
+
${escapeHtml(v.title)}
+
${escapeHtml(v.channel)}
+
+ ${v.duration ? clock(v.duration) : ''} + +
`).join('') : '
Nothing queued after this song.
'; + } + + // ---- drag to reorder (pointer events, works for touch + mouse) ---- + function onDragStart(e) { + const handle = e.target.closest('.rv-handle'); + if (!handle) return; + const row = handle.closest('.rv-row'); + e.preventDefault(); + const rows = [...$('rvQueue').querySelectorAll('.rv-row')]; + rc.drag = { row, rows, startY: e.clientY, from: Number(row.dataset.i), to: Number(row.dataset.i), h: row.offsetHeight }; + row.classList.add('dragging'); + row.setPointerCapture(e.pointerId); + row.addEventListener('pointermove', onDragMove); + row.addEventListener('pointerup', onDragEnd, { once: true }); + row.addEventListener('pointercancel', onDragEnd, { once: true }); + } + function onDragMove(e) { + const d = rc.drag; + if (!d) return; + const dy = e.clientY - d.startY; + d.row.style.transform = `translateY(${dy}px)`; + const pos = d.rows.indexOf(d.row); + const target = Math.max(0, Math.min(d.rows.length - 1, pos + Math.round(dy / d.h))); + d.to = Number(d.rows[target].dataset.i); + } + function onDragEnd() { + const d = rc.drag; + rc.drag = null; + if (!d) return; + d.row.removeEventListener('pointermove', onDragMove); + d.row.classList.remove('dragging'); + d.row.style.transform = ''; + if (d.to !== d.from) { + // Optimistic: reorder locally now, the host's queue push confirms it. + const items = rc.queue.items.slice(); + const [v] = items.splice(d.from, 1); + items.splice(d.to, 0, v); + rc.queue = { ...rc.queue, items }; + renderQueue(); + cmd('move', { from: d.from, to: d.to }); + } + } + + // ---- add to queue: search sheet ---- + function openAddSheet() { + const body = document.createElement('div'); + body.className = 'rv-add'; + body.innerHTML = ` +
+
`; + const results = body.querySelector('.rv-add-results'); + let found = []; + body.querySelector('form').addEventListener('submit', async (e) => { + e.preventDefault(); + const q = body.querySelector('.rv-add-q').value.trim(); + if (!q) return; + results.innerHTML = '
Searching…
'; + try { + const res = await API.search(q); + found = (res && res.ok && res.results) || []; + } catch { found = []; } + results.innerHTML = found.length ? found.slice(0, 20).map((v, i) => ` + `).join('') : '
No results.
'; + }); + results.addEventListener('click', (e) => { + const b = e.target.closest('[data-k]'); + if (!b) return; + const v = found[Number(b.dataset.k)]; + if (!v) return; + cmd('add', { video: { id: v.id, title: v.title, channel: v.channel, duration: v.duration, thumbnail: v.thumbnail } }); + b.classList.add('added'); + toast(`Added “${v.title}”`); + }); + showModal('+ Add to the screen’s queue', body, [{ label: 'Done', primary: true, onClick: closeModal }]); + setTimeout(() => body.querySelector('.rv-add-q').focus(), 50); + } + + async function doPair(codeRaw) { + const code = String(codeRaw || '').replace(/\D/g, ''); + const name = $('rvName').value.trim() || guessName('Phone'); + if (code.length !== 6) { $('rvPairMsg').textContent = 'Enter the 6-digit code from the screen.'; return; } + $('rvPairMsg').textContent = 'Pairing…'; + try { + const res = await fetch('/api/remote/pair', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, name }), + }); + const j = await res.json().catch(() => null); + if (!j || !j.ok) { $('rvPairMsg').textContent = '⚠ ' + ((j && j.error) || `HTTP ${res.status}`); return; } + save(PAIR_KEY, { roomId: j.roomId, remoteId: j.remoteId, token: j.token, hostName: j.hostName, name: j.name }); + $('rvCode').value = ''; + rcConnect(); + } catch { + $('rvPairMsg').textContent = '⚠ Network error — try again'; + } + } + + function openRemoteView({ code = '' } = {}) { + rc.open = true; + // This phone is the remote now — don't also play here. + try { if (Player.master && !Player.master.paused) Player.pause(); } catch { /* nothing loaded */ } + $('remoteView').classList.remove('hidden'); + document.body.classList.add('remote-open'); + if (code) { save(PAIR_KEY, null); showPairForm(); $('rvCode').value = code; doPair(code); return; } + if (load(PAIR_KEY)) rcConnect(); else showPairForm(); + } + + function closeRemoteView() { + rc.open = false; + clearTimeout(rc.retry); + clearInterval(rc.ping); + const ws = rc.ws; + rc.ws = null; + try { if (ws) ws.close(); } catch { /* gone */ } + $('remoteView').classList.add('hidden'); + document.body.classList.remove('remote-open'); + } + + function openChooser() { + const hosting = hostCfg().enabled; + const paired = load(PAIR_KEY); + const body = document.createElement('div'); + body.innerHTML = ``; + showModal('📱 Remote', body, [ + { label: paired ? `Control “${paired.hostName}”` : 'Use this phone as a remote', onClick: () => { closeModal(); openRemoteView(); } }, + { label: hosting ? 'This screen: remote on' : 'Control this screen from a phone', primary: true, onClick: () => { closeModal(); openHostModal(); } }, + ]); + } + + function bindUi() { + $('navRemoteBtn').addEventListener('click', () => { + if (typeof closeSidebar === 'function') { try { closeSidebar(); } catch { /* not open */ } } + openChooser(); + }); + $('remoteChip').addEventListener('click', openHostModal); + $('rvClose').addEventListener('click', closeRemoteView); + $('rvMenu').addEventListener('click', () => { + const p = load(PAIR_KEY); + showModal('Remote options', document.createTextNode(p ? `Paired with “${p.hostName}”.` : 'Not paired.'), [ + { label: 'Close', onClick: closeModal }, + ...(p ? [{ label: 'Unpair this phone', danger: true, onClick: () => { closeModal(); save(PAIR_KEY, null); try { rc.ws && rc.ws.close(); } catch { /* gone */ } showPairForm('Unpaired.'); } }] : []), + ]); + }); + $('rvPairBtn').addEventListener('click', () => doPair($('rvCode').value)); + $('rvCode').addEventListener('keydown', (e) => { if (e.key === 'Enter') doPair($('rvCode').value); }); + $('rvToggle').addEventListener('click', () => { + cmd('toggle'); + if (rc.state) { rc.state.cur = liveCur(); rc.stateAt = Date.now(); rc.state.paused = !rc.state.paused; renderRemote(); } + }); + $('rvPrev').addEventListener('click', () => cmd('prev')); + $('rvNext').addEventListener('click', () => cmd('next')); + const seek = $('rvSeek'); + seek.addEventListener('input', () => { + rc.seeking = true; + const dur = (rc.state && rc.state.dur) || 0; + $('rvCur').textContent = clock((Number(seek.value) / 1000) * dur); + }); + seek.addEventListener('change', () => { + const dur = (rc.state && rc.state.dur) || 0; + const t = (Number(seek.value) / 1000) * dur; + cmd('seek', { t }); + if (rc.state) { rc.state.cur = t; rc.stateAt = Date.now(); } + rc.seeking = false; + }); + $('rvVolume').addEventListener('input', (e) => { + const pct = Number(e.target.value); + $('rvVolVal').textContent = pct + '%'; + clearTimeout(rc.volTimer); + rc.volTimer = setTimeout(() => { rc.volTimer = null; cmd('volume', { v: pct / 100 }); }, 120); + }); + $('rvQueue').addEventListener('pointerdown', onDragStart); + $('rvQueue').addEventListener('click', (e) => { + const rm = e.target.closest('[data-rm]'); + if (rm) { cmd('remove', { i: Number(rm.dataset.rm) }); return; } + if (e.target.closest('.rv-handle')) return; + const row = e.target.closest('.rv-row'); + if (row) cmd('jump', { i: Number(row.dataset.i) }); + }); + $('rvAdd').addEventListener('click', openAddSheet); + $('rvSleep').addEventListener('click', () => { + const options = [0, 15, 30, 45, 60, 90]; + const curMin = rc.state && rc.state.sleep ? Math.ceil(rc.state.sleep / 60) : 0; + const next = options.find((o) => o > curMin) ?? 0; + cmd('sleep', { min: next }); + toast(next ? `Screen pauses in ${next} min` : 'Sleep timer off'); + }); + $('rvService').addEventListener('click', () => cmd('service', { on: !(rc.state && rc.state.service) })); + setInterval(() => { if (rc.open) renderSeek(); }, 500); + // Hosts keep phones current once a second while anything plays. + setInterval(() => { if (host.ws && host.remotes.length) { pushState(); pushQueue(); } }, 1000); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && rc.open && !rc.ws) rcConnect(); + }); + } + + function boot() { + bindUi(); + if (hostCfg().enabled) hostConnect(); + // A scanned QR / shared link: /?pair=482913 + let code = ''; + try { code = (new URLSearchParams(location.search).get('pair') || '').replace(/\D/g, ''); } catch { /* no URL */ } + if (code) { + try { + const u = new URL(location.href); + u.searchParams.delete('pair'); + history.replaceState(null, '', u.pathname + u.search + u.hash); + } catch { /* leave the URL */ } + openRemoteView({ code }); + } + } + + return { boot, changed }; +})(); + function updateProgress() { const cur = Player.master.currentTime || 0; const dur = Player.master.duration || current?.meta?.duration || 0; @@ -3146,6 +3800,7 @@ function playQueue(index = 0) { function renderUpNext() { updateServiceUpNext(); + Remote.changed(); const upcoming = queue.slice(queueIndex + 1); if (!upcoming.length) { $('upnext').classList.add('hidden'); return; } $('upnext').classList.remove('hidden'); @@ -5277,6 +5932,7 @@ function wireUI() { }); document.querySelectorAll('.nav-item').forEach((b) => { + if (!b.dataset.view) return; // e.g. 📱 Remote opens a panel, not a view b.addEventListener('click', () => { view = { type: b.dataset.view }; render(); }); }); @@ -6216,6 +6872,7 @@ async function boot() { // this must run once we know which one (if any) this device is linked to. await checkPlaylistInbox(); startPlaylistInboxWatch(); + Remote.boot(); applyAppearance(); updateProfileStatus(); updateLoopRepeatButtons(); diff --git a/frontend/index.html b/frontend/index.html index 18804e1..c89e362 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -40,6 +40,7 @@ +
@@ -362,6 +363,62 @@
+ + + + + +