Add phone remote control: pair a phone with a 6-digit code or QR to drive a desktop instance's playback, queue, volume, sleep timer and service mode

This commit is contained in:
Jonathan Sykes
2026-09-18 20:19:00 +08:00
parent 524db986d2
commit b82f17cd46
9 changed files with 1300 additions and 4 deletions

View File

@@ -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 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. 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
`<origin>/?pair=<code>`; the app consumes and strips `?pair=` at boot.
## Local dev ## Local dev
```bash ```bash

View File

@@ -12,6 +12,9 @@ services:
# Unlocks /admin (API tokens, lyric/chapter history + restore). Set it in # Unlocks /admin (API tokens, lyric/chapter history + restore). Set it in
# Dokploy's Environment tab — never commit it. Unset = admin disabled. # Dokploy's Environment tab — never commit it. Unset = admin disabled.
ADMIN_PASSWORD: "${ADMIN_PASSWORD:-}" 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 # Optional: override yt-dlp binary path if you mount a custom one
# YTDLP_PATH: "/usr/local/bin/yt-dlp" # YTDLP_PATH: "/usr/local/bin/yt-dlp"
# Server media cache (server/media-cache.js) — defaults shown. # Server media cache (server/media-cache.js) — defaults shown.

View File

@@ -1630,6 +1630,7 @@ const Player = {
afterLoad() { afterLoad() {
addToHistory(current.meta); addToHistory(current.meta);
Notes.load(current.meta); Notes.load(current.meta);
Remote.changed();
buildQualityMenu(); buildQualityMenu();
els.playerPane.classList.remove('empty'); els.playerPane.classList.remove('empty');
els.controls.classList.remove('hidden'); els.controls.classList.remove('hidden');
@@ -2191,6 +2192,7 @@ function wirePlayerEvents() {
} }
function updatePlayBtn() { function updatePlayBtn() {
Remote.changed();
els.playBtn.textContent = Player.master.paused ? '▶' : '⏸'; els.playBtn.textContent = Player.master.paused ? '▶' : '⏸';
$('miniPlayBtn').textContent = Player.master.paused ? '▶' : '⏸'; $('miniPlayBtn').textContent = Player.master.paused ? '▶' : '⏸';
const spBtn = $('servicePlayBtn'); const spBtn = $('servicePlayBtn');
@@ -2985,6 +2987,658 @@ const Notes = (() => {
return { load, tick, addBookmark, handleSyncKey }; 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 ? `
<p class="modal-text">Let a phone drive this screen — play/pause, seek, volume, the queue and service mode —
while this computer plays to the TV or speakers. Pairing uses a one-time 6-digit code; nothing to sign in to.</p>`
: host.replaced ? `<p class="modal-text">Another tab on this computer took over as the remote-controlled screen.</p>`
: !live ? `<p class="modal-text">Connecting to the server…</p>`
: `
<div class="rh-pair">
<img class="rh-qr" alt="QR code to pair a phone" src="/api/remote/qr/${host.code}" />
<div>
<div class="rh-label">Pairing code</div>
<div class="rh-code">${host.code.slice(0, 3)} ${host.code.slice(3)}</div>
<div class="rh-sub">Valid ${mins} more min · one use</div>
<div class="rh-sub">On the phone: open this app → 📱 Remote → <b>Use this phone as a remote</b>, or scan the QR code.</div>
</div>
</div>
<div class="rh-label">Connected phones</div>
${host.remotes.length ? host.remotes.map((r) => `
<div class="rh-remote"><span>📱 ${escapeHtml(r.name)}</span><button class="mk-btn" type="button" data-kick="${escapeHtml(r.rid)}" title="Disconnect">✕</button></div>`).join('')
: '<div class="rh-sub">None yet.</div>'}`;
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 = `
<div class="rh-body"></div>
<label class="rh-name-row">This screen's name <input class="rh-name" maxlength="40" /></label>
<div class="rh-buttons">
<button class="np-btn" type="button" data-act="start">▶ Start — control from a phone</button>
<button class="np-btn" type="button" data-act="code">↻ New code</button>
<button class="np-btn" type="button" data-act="unpair">Unpair all phones</button>
<button class="np-btn" type="button" data-act="stop">Stop</button>
</div>`;
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) => `
<div class="rv-row" data-i="${v.i}">
<span class="rv-handle" aria-label="Drag to reorder">⠿</span>
<div class="rv-row-text">
<div class="rv-row-title">${escapeHtml(v.title)}</div>
<div class="rv-row-sub">${escapeHtml(v.channel)}</div>
</div>
<span class="rv-row-dur">${v.duration ? clock(v.duration) : ''}</span>
<button class="rv-row-x" type="button" data-rm="${v.i}" aria-label="Remove">✕</button>
</div>`).join('') : '<div class="rv-empty">Nothing queued after this song.</div>';
}
// ---- 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 = `
<form class="rv-add-form"><input class="rv-add-q" type="search" placeholder="Search YouTube…" /><button class="np-btn" type="submit">Search</button></form>
<div class="rv-add-results"></div>`;
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 = '<div class="rv-empty">Searching…</div>';
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) => `
<button class="rv-add-item" type="button" data-k="${i}">
<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" />
<span><b>${escapeHtml(v.title || '')}</b><small>${escapeHtml(v.channel || '')}${v.duration ? ' · ' + clock(v.duration) : ''}</small></span>
</button>`).join('') : '<div class="rv-empty">No results.</div>';
});
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 = `<p class="modal-text">Play on a computer hooked to the TV or speakers, and drive it from your phone.</p>`;
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() { function updateProgress() {
const cur = Player.master.currentTime || 0; const cur = Player.master.currentTime || 0;
const dur = Player.master.duration || current?.meta?.duration || 0; const dur = Player.master.duration || current?.meta?.duration || 0;
@@ -3146,6 +3800,7 @@ function playQueue(index = 0) {
function renderUpNext() { function renderUpNext() {
updateServiceUpNext(); updateServiceUpNext();
Remote.changed();
const upcoming = queue.slice(queueIndex + 1); const upcoming = queue.slice(queueIndex + 1);
if (!upcoming.length) { $('upnext').classList.add('hidden'); return; } if (!upcoming.length) { $('upnext').classList.add('hidden'); return; }
$('upnext').classList.remove('hidden'); $('upnext').classList.remove('hidden');
@@ -5277,6 +5932,7 @@ function wireUI() {
}); });
document.querySelectorAll('.nav-item').forEach((b) => { 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(); }); 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. // this must run once we know which one (if any) this device is linked to.
await checkPlaylistInbox(); await checkPlaylistInbox();
startPlaylistInboxWatch(); startPlaylistInboxWatch();
Remote.boot();
applyAppearance(); applyAppearance();
updateProfileStatus(); updateProfileStatus();
updateLoopRepeatButtons(); updateLoopRepeatButtons();

View File

@@ -40,6 +40,7 @@
<button class="nav-item" data-view="saved">💾 Saved</button> <button class="nav-item" data-view="saved">💾 Saved</button>
<button class="nav-item" data-view="downloads">⬇ Downloads <span id="navDlCount" class="nav-badge hidden">0</span></button> <button class="nav-item" data-view="downloads">⬇ Downloads <span id="navDlCount" class="nav-badge hidden">0</span></button>
<button class="nav-item" data-view="settings">⚙ Settings</button> <button class="nav-item" data-view="settings">⚙ Settings</button>
<button class="nav-item" id="navRemoteBtn" type="button" title="Control this screen from a phone, or use this phone as a remote">📱 Remote</button>
</nav> </nav>
<div class="pl-header"> <div class="pl-header">
@@ -362,6 +363,62 @@
</div> </div>
<!-- YouTube-style bottom navigation bar (portrait PWA mode only) --> <!-- YouTube-style bottom navigation bar (portrait PWA mode only) -->
<!-- Desktop: shown while phones are driving this screen (remote.js) -->
<button id="remoteChip" class="remote-chip hidden" type="button" title="Phone remote settings">
<span class="remote-chip-dot"></span><span id="remoteChipText">Remote connected</span>
</button>
<!-- Phone: remote control for a desktop instance -->
<section id="remoteView" class="remote-view hidden" aria-label="Phone remote">
<div class="rv-top">
<button id="rvClose" class="rv-icon" type="button" aria-label="Close remote">✕</button>
<div class="rv-host">
<div id="rvHostName" class="rv-host-name">Remote</div>
<div id="rvStatus" class="rv-status">Not connected</div>
</div>
<button id="rvMenu" class="rv-icon" type="button" aria-label="Remote options">⋯</button>
</div>
<div id="rvPair" class="rv-pair">
<div class="rv-pair-title">Connect to a screen</div>
<p class="rv-pair-help">On the computer that plays to the TV or speakers, open <b>📱 Remote</b> → <b>Control from a phone</b>, then enter the 6-digit code shown there (or scan its QR code with your camera).</p>
<input id="rvCode" class="rv-code" inputmode="numeric" autocomplete="one-time-code" maxlength="7" placeholder="000 000" />
<input id="rvName" class="rv-name" maxlength="40" placeholder="This phone's name" />
<button id="rvPairBtn" class="rv-primary" type="button">Pair</button>
<div id="rvPairMsg" class="rv-pair-msg"></div>
</div>
<div id="rvControl" class="rv-control hidden">
<div class="rv-now">
<img id="rvThumb" class="rv-thumb" alt="" />
<div class="rv-now-text">
<div id="rvTitle" class="rv-title">Nothing playing</div>
<div id="rvChannel" class="rv-channel"></div>
</div>
</div>
<div class="rv-seek">
<input id="rvSeek" type="range" min="0" max="1000" value="0" />
<div class="rv-times"><span id="rvCur">0:00</span><span id="rvDur">0:00</span></div>
</div>
<div class="rv-transport">
<button id="rvPrev" class="rv-tbtn" type="button" aria-label="Previous">⏮</button>
<button id="rvToggle" class="rv-tbtn rv-play" type="button" aria-label="Play or pause">▶</button>
<button id="rvNext" class="rv-tbtn" type="button" aria-label="Next">⏭</button>
</div>
<div class="rv-volume">
<span>🔊</span><input id="rvVolume" type="range" min="0" max="100" value="100" /><span id="rvVolVal">100%</span>
</div>
<div class="rv-queue-head"><span>Up next</span><span id="rvQueueCount"></span></div>
<div id="rvQueue" class="rv-queue"></div>
<div class="rv-queue-hint">Tap a song to play it · drag ⠿ to reorder · ✕ removes</div>
<div class="rv-actions">
<button id="rvAdd" class="rv-action" type="button">+ Add to queue</button>
<button id="rvSleep" class="rv-action" type="button">⏱ Sleep</button>
<button id="rvService" class="rv-action" type="button">🎤 Service mode</button>
</div>
</div>
</section>
<nav id="bottomNav" class="bottom-nav" aria-label="Main navigation"> <nav id="bottomNav" class="bottom-nav" aria-label="Main navigation">
<button class="bottom-nav-btn active" data-view="search" aria-label="Search"> <button class="bottom-nav-btn active" data-view="search" aria-label="Search">
<span class="bottom-nav-icon">🔍</span> <span class="bottom-nav-icon">🔍</span>

View File

@@ -3605,3 +3605,113 @@ html[data-layout="bento-hub"] .mini-bar-inner {
.lyr-auto { margin-left: auto; } .lyr-auto { margin-left: auto; }
.notes-status { display: none; } .notes-status { display: none; }
} }
/* ============================================================================
* Phone remote (Remote in app.js, server/remote.js)
* ========================================================================== */
.remote-chip {
position: fixed; left: 16px; bottom: 18px; z-index: 150;
display: inline-flex; align-items: center; gap: 8px;
padding: 8px 14px; border-radius: 999px; cursor: pointer;
background: var(--bg-3); color: var(--text); border: 1px solid var(--line);
box-shadow: var(--shadow); font-size: 12px; font-weight: 600;
}
.remote-chip-dot { width: 8px; height: 8px; border-radius: 50%; background: #7ee0a8; box-shadow: 0 0 0 3px rgba(126,224,168,0.2); }
body.service-open .remote-chip { bottom: auto; top: 14px; }
.remote-host .rh-pair { display: flex; gap: 16px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
.rh-qr { width: 132px; height: 132px; background: #fff; border-radius: 10px; padding: 4px; box-sizing: border-box; }
.rh-label { font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-dim); margin: 4px 0; }
.rh-code { font-family: var(--mono); font-size: 34px; font-weight: 700; letter-spacing: 0.08em; color: var(--text); }
.rh-sub { font-size: 12px; color: var(--text-2); margin-top: 4px; max-width: 320px; line-height: 1.45; }
.rh-remote { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid var(--line-soft); font-size: 13px; }
.rh-name-row { display: flex; flex-direction: column; gap: 4px; margin-top: 14px; font-size: 11px; color: var(--text-dim); }
.rh-name { background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 8px 10px; font-size: 14px; }
.rh-buttons { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.remote-view {
position: fixed; inset: 0; z-index: 180;
display: flex; flex-direction: column;
background: var(--bg);
padding: max(12px, env(safe-area-inset-top)) 16px max(14px, env(safe-area-inset-bottom));
overflow-y: auto; overscroll-behavior: contain;
}
body.remote-open { overflow: hidden; }
.rv-top { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.rv-icon {
flex: none; width: 38px; height: 38px; border-radius: 50%;
background: var(--bg-2); border: 1px solid var(--line); color: var(--text-2);
font-size: 15px; cursor: pointer;
}
.rv-host { flex: 1; min-width: 0; text-align: center; }
.rv-host-name { font-weight: 700; font-size: 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rv-status { font-family: var(--mono); font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-dim); margin-top: 2px; }
.rv-status.ok { color: #7ee0a8; }
.rv-pair { max-width: 420px; margin: 8vh auto 0; width: 100%; display: flex; flex-direction: column; gap: 12px; }
.rv-pair-title { font-family: var(--display); font-size: 26px; font-weight: 700; }
.rv-pair-help { margin: 0; font-size: 13px; line-height: 1.55; color: var(--text-2); }
.rv-code {
font-family: var(--mono); font-size: 34px; letter-spacing: 0.2em; text-align: center;
background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: var(--radius);
padding: 14px 10px; width: 100%; box-sizing: border-box;
}
.rv-name { background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 11px 12px; font-size: 15px; }
.rv-primary {
background: var(--accent); color: #fff; border: none; border-radius: var(--radius);
padding: 14px; font-size: 16px; font-weight: 700; cursor: pointer;
}
.rv-pair-msg { min-height: 1.2em; font-size: 13px; color: var(--accent-bright); text-align: center; }
.rv-control { max-width: 520px; margin: 0 auto; width: 100%; display: flex; flex-direction: column; gap: 14px; }
.rv-now { display: flex; gap: 14px; align-items: center; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px; }
.rv-thumb { width: 88px; height: 50px; object-fit: cover; border-radius: 8px; background: var(--bg-3); flex: none; }
.rv-now-text { min-width: 0; }
.rv-title { font-weight: 700; font-size: 16px; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.rv-channel { font-size: 12px; color: var(--text-2); margin-top: 2px; }
.rv-seek input, .rv-volume input { width: 100%; accent-color: var(--accent); }
.rv-times { display: flex; justify-content: space-between; font-family: var(--mono); font-size: 11px; color: var(--text-dim); }
.rv-transport { display: flex; justify-content: center; align-items: center; gap: 22px; }
.rv-tbtn {
width: 58px; height: 58px; border-radius: 50%; cursor: pointer;
background: var(--bg-2); border: 1px solid var(--line); color: var(--text); font-size: 20px;
}
.rv-tbtn.rv-play { width: 76px; height: 76px; background: var(--accent); border-color: var(--accent); color: #fff; font-size: 26px; }
.rv-tbtn:active { transform: scale(0.96); }
.rv-volume { display: flex; align-items: center; gap: 10px; font-size: 13px; color: var(--text-2); }
.rv-volume span:last-child { font-family: var(--mono); font-size: 11px; width: 38px; text-align: right; }
.rv-queue-head { display: flex; justify-content: space-between; font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-dim); }
.rv-queue { display: flex; flex-direction: column; gap: 6px; }
.rv-row {
display: flex; align-items: center; gap: 10px; padding: 10px;
background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius-sm);
cursor: pointer; touch-action: pan-y; position: relative;
}
.rv-row.dragging { z-index: 2; border-color: var(--accent); box-shadow: var(--shadow); cursor: grabbing; }
.rv-handle { color: var(--text-dim); font-size: 18px; padding: 4px 2px; cursor: grab; touch-action: none; user-select: none; }
.rv-row-text { flex: 1; min-width: 0; }
.rv-row-title { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rv-row-sub { font-size: 11px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rv-row-dur { font-family: var(--mono); font-size: 11px; color: var(--text-dim); }
.rv-row-x { background: transparent; border: none; color: var(--text-dim); font-size: 14px; padding: 6px; cursor: pointer; }
.rv-empty { font-size: 13px; color: var(--text-dim); padding: 10px 2px; }
.rv-queue-hint { font-size: 11px; color: var(--text-dim); text-align: center; }
.rv-actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.rv-action {
background: var(--bg-2); border: 1px solid var(--line); color: var(--text-2);
border-radius: var(--radius-sm); padding: 12px 6px; font-size: 13px; cursor: pointer;
}
.rv-action.on { border-color: var(--accent); color: var(--text); }
.rv-add-form { display: flex; gap: 8px; margin-bottom: 10px; }
.rv-add-q { flex: 1; min-width: 0; background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 9px 10px; font-size: 15px; }
.rv-add-results { display: flex; flex-direction: column; gap: 6px; max-height: 55vh; overflow-y: auto; }
.rv-add-item {
display: flex; gap: 10px; align-items: center; text-align: left; cursor: pointer;
background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 6px; color: var(--text);
}
.rv-add-item img { width: 72px; height: 40px; object-fit: cover; border-radius: 6px; flex: none; }
.rv-add-item span { display: flex; flex-direction: column; min-width: 0; font-size: 13px; }
.rv-add-item b { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rv-add-item small { color: var(--text-dim); font-size: 11px; }
.rv-add-item.added { border-color: #7ee0a8; }

View File

@@ -6,11 +6,12 @@
"scripts": { "scripts": {
"start": "bun server.js", "start": "bun server.js",
"dev": "bun --hot server.js", "dev": "bun --hot server.js",
"test": "bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js" "test": "bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./remote.test.js"
}, },
"dependencies": { "dependencies": {
"hono": "^4.7.10",
"@hono/node-server": "^1.14.0", "@hono/node-server": "^1.14.0",
"@libsql/client": "^0.14.0" "@libsql/client": "^0.14.0",
"hono": "^4.7.10",
"qrcode": "^1.5.4"
} }
} }

256
server/remote.js Normal file
View File

@@ -0,0 +1,256 @@
/* ============================================================================
* remote.js — phone-as-remote relay for a desktop instance (TV / speakers)
*
* Both ends are ordinary browser tabs of this web app, so the server relays:
*
* desktop ("host") ──ws──▶ server ◀──ws── phone ("remote")
*
* Host: opens /ws/remote?role=host&secret=…&name=…. The secret is generated
* and kept by the desktop (localStorage); the room id is derived from it, so
* a reloaded TV tab — or a restarted server — gets the same room back.
* Pairing: the host is shown a one-time 6-digit code (10 min). The phone POSTs
* it to /api/remote/pair and receives { roomId, remoteId, token } where
* token = HMAC(secret, remoteId). Nothing about a paired phone is stored on
* the server: the token is re-checked against the connected host's secret on
* every connect, so a server restart keeps phones paired, and "Unpair all"
* on the desktop (a new secret) revokes every phone at once.
* Remote: opens /ws/remote?role=remote&room=…&rid=…&token=…&name=….
*
* Messages (JSON):
* host → remotes {type:'state', state} {type:'queue', queue} {type:'pong', rid, t}
* remote → host {type:'cmd', cmd, …args} {type:'ping', t}
* server → host {type:'hello', roomId, code, codeExpires, remotes}
* {type:'code', code, codeExpires} {type:'remotes', remotes}
* {type:'cmd', from, …} {type:'ping', rid, t}
* server → remote {type:'hello', hostName, state, queue} {type:'host-offline'}
*
* REMOTE_SAME_NETWORK=1 makes pairing require the phone and the desktop to
* reach the server from the same public IP (i.e. the same home/church Wi-Fi).
* ========================================================================== */
import { createHash, createHmac, randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
export const REMOTE_COMMANDS = new Set([
'toggle', 'play', 'pause', 'next', 'prev', 'seek', 'volume',
'jump', 'remove', 'move', 'add', 'sleep', 'service',
]);
const CODE_TTL_MS = 10 * 60_000;
const ROOM_IDLE_MS = 60 * 60_000;
const MAX_MSG = 256 * 1024;
export function roomIdFor(secret) {
return createHash('sha256').update('ytp-remote:' + secret).digest('hex').slice(0, 20);
}
export function remoteToken(secret, remoteId) {
return createHmac('sha256', secret).update(remoteId).digest('base64url');
}
function sameToken(a, b) {
const x = Buffer.from(String(a)), y = Buffer.from(String(b));
return x.length === y.length && timingSafeEqual(x, y);
}
const cleanName = (v, fallback) =>
String(v || '').replace(/[\u0000-\u001f\u007f<>]+/g, ' ').trim().slice(0, 40) || fallback;
export function createRemoteHub({ requireSameNetwork = false } = {}) {
const rooms = new Map(); // roomId → room
const codes = new Map(); // code → { roomId, expires }
const pairFails = new Map(); // ip → [timestamps]
const send = (ws, msg) => { try { ws.send(JSON.stringify(msg)); } catch { /* socket gone */ } };
function remoteList(room) {
return [...room.remotes.entries()].map(([rid, r]) => ({ rid, name: r.name, since: r.since }));
}
function issueCode(room) {
if (room.code) codes.delete(room.code);
let code;
do { code = String(randomInt(0, 1_000_000)).padStart(6, '0'); } while (codes.has(code));
room.code = code;
room.codeExpires = Date.now() + CODE_TTL_MS;
codes.set(code, { roomId: room.id, expires: room.codeExpires });
return code;
}
function sweep() {
const now = Date.now();
for (const [code, c] of codes) if (c.expires < now) codes.delete(code);
for (const [id, room] of rooms) {
if (!room.host && !room.remotes.size && now - room.lastSeen > ROOM_IDLE_MS) {
if (room.code) codes.delete(room.code);
rooms.delete(id);
}
}
}
const sweeper = setInterval(sweep, 60_000);
sweeper.unref?.();
// ---- HTTP: pairing ----
function pair({ code, name, ip }) {
const now = Date.now();
const fails = (pairFails.get(ip) || []).filter((t) => now - t < 10 * 60_000);
if (fails.length >= 10) return { status: 429, body: { ok: false, error: 'too many attempts — wait a few minutes' } };
const fail = (status, error) => {
fails.push(now);
pairFails.set(ip, fails);
if (pairFails.size > 5000) pairFails.clear();
return { status, body: { ok: false, error } };
};
const c = codes.get(String(code || '').replace(/\D/g, ''));
const room = c && c.expires >= now ? rooms.get(c.roomId) : null;
if (!room || !room.host) return fail(404, 'code not found or expired — check the code on the desktop');
if (requireSameNetwork && room.hostIp && ip !== room.hostIp) {
return fail(403, 'the phone and the desktop must be on the same network');
}
const remoteId = randomBytes(8).toString('hex');
const token = remoteToken(room.secret, remoteId);
// One-time code: the desktop immediately shows a fresh one.
issueCode(room);
send(room.host, { type: 'code', code: room.code, codeExpires: room.codeExpires });
return {
status: 200,
body: { ok: true, roomId: room.id, remoteId, token, hostName: room.hostName, name: cleanName(name, 'Phone') },
};
}
// ---- WebSocket ----
// Returns a Response on rejection, undefined when upgraded.
function upgrade(req, server, ip) {
const u = new URL(req.url);
const q = (k) => u.searchParams.get(k) || '';
const role = q('role');
if (role === 'host') {
const secret = q('secret');
if (!/^[A-Za-z0-9_-]{32,128}$/.test(secret)) return new Response('bad secret', { status: 400 });
const ok = server.upgrade(req, { data: { role, secret, name: cleanName(q('name'), 'Desktop'), ip } });
return ok ? undefined : new Response('upgrade failed', { status: 400 });
}
if (role === 'remote') {
const data = { role, roomId: q('room'), rid: q('rid'), token: q('token'), name: cleanName(q('name'), 'Phone'), ip };
if (!/^[0-9a-f]{20}$/.test(data.roomId) || !/^[0-9a-f]{16}$/.test(data.rid) || !data.token) {
return new Response('bad pairing', { status: 400 });
}
const ok = server.upgrade(req, { data });
return ok ? undefined : new Response('upgrade failed', { status: 400 });
}
return new Response('unknown role', { status: 400 });
}
function openHost(ws) {
const d = ws.data;
const id = roomIdFor(d.secret);
let room = rooms.get(id);
if (!room) {
room = { id, secret: d.secret, host: null, hostName: d.name, hostIp: d.ip, remotes: new Map(), state: null, queue: null, code: null, codeExpires: 0, lastSeen: Date.now() };
rooms.set(id, room);
}
if (room.host && room.host !== ws) {
try { room.host.close(4000, 'replaced by a newer desktop tab'); } catch { /* already closed */ }
}
room.host = ws;
room.hostName = d.name;
room.hostIp = d.ip;
room.lastSeen = Date.now();
d.roomId = id;
issueCode(room);
send(ws, { type: 'hello', roomId: id, code: room.code, codeExpires: room.codeExpires, remotes: remoteList(room) });
for (const r of room.remotes.values()) send(r.ws, { type: 'hello', hostName: room.hostName, state: room.state, queue: room.queue });
}
function openRemote(ws) {
const d = ws.data;
const room = rooms.get(d.roomId);
// Without a connected desktop there is no secret to check the token
// against — the phone keeps retrying until the desktop is back.
if (!room || !room.host) { ws.close(4004, 'desktop offline'); return; }
if (!sameToken(d.token, remoteToken(room.secret, d.rid))) { ws.close(4001, 'not paired'); return; }
const prev = room.remotes.get(d.rid);
if (prev && prev.ws !== ws) { try { prev.ws.close(4000, 'replaced'); } catch { /* gone */ } }
room.remotes.set(d.rid, { ws, name: d.name, since: Date.now() });
room.lastSeen = Date.now();
send(ws, { type: 'hello', hostName: room.hostName, state: room.state, queue: room.queue });
send(room.host, { type: 'remotes', remotes: remoteList(room), joined: d.name });
}
function onMessage(ws, raw) {
if (typeof raw !== 'string' || raw.length > MAX_MSG) return;
let m;
try { m = JSON.parse(raw); } catch { return; }
if (!m || typeof m !== 'object') return;
const d = ws.data;
const room = rooms.get(d.roomId);
if (!room) return;
room.lastSeen = Date.now();
if (d.role === 'host') {
if (room.host !== ws) return;
if (m.type === 'state') {
room.state = m.state || null;
for (const r of room.remotes.values()) send(r.ws, { type: 'state', state: room.state });
} else if (m.type === 'queue') {
const q = m.queue && typeof m.queue === 'object' ? m.queue : {};
room.queue = { items: Array.isArray(q.items) ? q.items.slice(0, 200) : [], idx: Number.isInteger(q.idx) ? q.idx : -1 };
for (const r of room.remotes.values()) send(r.ws, { type: 'queue', queue: room.queue });
} else if (m.type === 'pong') {
const r = room.remotes.get(m.rid);
if (r) send(r.ws, { type: 'pong', t: m.t });
} else if (m.type === 'new-code') {
issueCode(room);
send(ws, { type: 'code', code: room.code, codeExpires: room.codeExpires });
} else if (m.type === 'unpair-all') {
for (const r of room.remotes.values()) { try { r.ws.close(4001, 'unpaired'); } catch { /* gone */ } }
room.remotes.clear();
if (room.code) codes.delete(room.code);
rooms.delete(room.id); // the desktop reconnects with a new secret
} else if (m.type === 'kick') {
const r = room.remotes.get(m.rid);
if (r) { try { r.ws.close(4001, 'removed by the desktop'); } catch { /* gone */ } }
}
return;
}
// remote
const me = room.remotes.get(d.rid);
if (!me || me.ws !== ws) return;
if (!room.host) { send(ws, { type: 'host-offline' }); return; }
if (m.type === 'ping') {
send(room.host, { type: 'ping', rid: d.rid, t: m.t });
} else if (m.type === 'cmd' && REMOTE_COMMANDS.has(m.cmd)) {
const { type, ...args } = m;
send(room.host, { type: 'cmd', from: me.name, rid: d.rid, ...args });
}
}
function onClose(ws) {
const d = ws.data;
const room = rooms.get(d.roomId);
if (!room) return;
room.lastSeen = Date.now();
if (d.role === 'host') {
if (room.host !== ws) return;
room.host = null;
if (room.code) { codes.delete(room.code); room.code = null; }
for (const r of room.remotes.values()) send(r.ws, { type: 'host-offline' });
} else {
const r = room.remotes.get(d.rid);
if (r && r.ws === ws) {
room.remotes.delete(d.rid);
if (room.host) send(room.host, { type: 'remotes', remotes: remoteList(room), left: r.name });
}
}
}
const websocket = {
maxPayloadLength: MAX_MSG,
idleTimeout: 120,
open(ws) { if (ws.data.role === 'host') openHost(ws); else openRemote(ws); },
message(ws, msg) { onMessage(ws, typeof msg === 'string' ? msg : Buffer.from(msg).toString('utf8')); },
close(ws) { onClose(ws); },
};
return { pair, upgrade, websocket, _rooms: rooms, stop() { clearInterval(sweeper); } };
}

153
server/remote.test.js Normal file
View File

@@ -0,0 +1,153 @@
// Phone-remote relay: real Bun server + real WebSocket clients.
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { createRemoteHub, roomIdFor } from './remote.js';
const SECRET = 'a'.repeat(43);
let hub, server, base, ipFor;
function start(opts) {
hub = createRemoteHub(opts);
ipFor = () => '203.0.113.1';
server = Bun.serve({
port: 0,
async fetch(req, srv) {
const u = new URL(req.url);
if (u.pathname === '/ws/remote') return hub.upgrade(req, srv, ipFor(req));
if (u.pathname === '/pair') {
const body = await req.json();
const r = hub.pair({ ...body, ip: ipFor(req) });
return Response.json(r.body, { status: r.status });
}
return new Response('nope', { status: 404 });
},
websocket: hub.websocket,
});
base = `localhost:${server.port}`;
}
// A WebSocket client that queues messages and lets a test await the next
// one of a given type.
function client(query) {
const ws = new WebSocket(`ws://${base}/ws/remote?${new URLSearchParams(query)}`);
const inbox = [];
const waiters = [];
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
const i = waiters.findIndex((w) => w.type === m.type);
if (i >= 0) waiters.splice(i, 1)[0].resolve(m);
else inbox.push(m);
};
const closed = new Promise((r) => { ws.onclose = (e) => r(e.code); });
return {
ws,
closed,
send: (m) => ws.send(JSON.stringify(m)),
next(type, ms = 2000) {
const i = inbox.findIndex((m) => m.type === type);
if (i >= 0) return Promise.resolve(inbox.splice(i, 1)[0]);
return new Promise((resolve, reject) => {
waiters.push({ type, resolve });
setTimeout(() => reject(new Error(`timeout waiting for ${type}`)), ms);
});
},
opened: new Promise((r) => { ws.onopen = r; }),
};
}
const pair = async (code, name = 'Josh iPhone') => {
const res = await fetch(`http://${base}/pair`, { method: 'POST', body: JSON.stringify({ code, name }) });
return { status: res.status, body: await res.json() };
};
describe('remote relay', () => {
beforeAll(() => start());
afterAll(() => { server.stop(true); hub.stop(); });
let host, remote, creds;
test('host gets a room and a 6-digit code', async () => {
host = client({ role: 'host', secret: SECRET, name: 'Living Room TV' });
const hello = await host.next('hello');
expect(hello.roomId).toBe(roomIdFor(SECRET));
expect(hello.code).toMatch(/^\d{6}$/);
host.code = hello.code;
});
test('wrong code is refused; right code pairs once and rotates', async () => {
expect((await pair('000000')).status === 404 || host.code === '000000').toBe(true);
const ok = await pair(host.code);
expect(ok.status).toBe(200);
expect(ok.body.hostName).toBe('Living Room TV');
creds = ok.body;
const fresh = await host.next('code');
expect(fresh.code).not.toBe(host.code);
expect((await pair(host.code)).status).toBe(404); // one-time
host.code = fresh.code;
});
test('remote connects, host sees it, state flows down, commands flow up', async () => {
host.send({ type: 'state', state: { title: 'Living Hope', cur: 84, paused: false } });
host.send({ type: 'queue', queue: { items: [{ id: 'x', title: 'House of the Lord' }], idx: -1 } });
remote = client({ role: 'remote', room: creds.roomId, rid: creds.remoteId, token: creds.token, name: 'Josh iPhone' });
const hello = await remote.next('hello');
expect(hello.state.title).toBe('Living Hope');
expect(hello.queue.items[0].title).toBe('House of the Lord');
const list = await host.next('remotes');
expect(list.remotes.map((r) => r.name)).toEqual(['Josh iPhone']);
remote.send({ type: 'cmd', cmd: 'seek', t: 120 });
remote.send({ type: 'cmd', cmd: 'rm -rf' }); // not a command → dropped
remote.send({ type: 'cmd', cmd: 'next' });
const c1 = await host.next('cmd');
expect(c1).toMatchObject({ cmd: 'seek', t: 120, from: 'Josh iPhone' });
expect((await host.next('cmd')).cmd).toBe('next');
host.send({ type: 'state', state: { title: 'Living Hope', cur: 121, paused: true } });
expect((await remote.next('state')).state.cur).toBe(121);
});
test('ping goes through the host and back', async () => {
remote.send({ type: 'ping', t: 42 });
const p = await host.next('ping');
expect(p.t).toBe(42);
host.send({ type: 'pong', rid: p.rid, t: p.t });
expect((await remote.next('pong')).t).toBe(42);
});
test('a forged token is rejected', async () => {
const bad = client({ role: 'remote', room: creds.roomId, rid: creds.remoteId, token: 'forged', name: 'x' });
expect(await bad.closed).toBe(4001);
});
test('desktop offline: remotes are told, reconnects wait; desktop back → phone still paired', async () => {
host.ws.close();
await remote.next('host-offline');
const early = client({ role: 'remote', room: creds.roomId, rid: creds.remoteId, token: creds.token, name: 'x' });
expect(await early.closed).toBe(4004);
host = client({ role: 'host', secret: SECRET, name: 'Living Room TV' });
await host.next('hello');
expect((await remote.next('hello')).hostName).toBe('Living Room TV');
});
test('unpair-all disconnects every phone', async () => {
host.send({ type: 'unpair-all' });
expect(await remote.closed).toBe(4001);
host.ws.close();
});
});
describe('same-network pairing', () => {
beforeAll(() => start({ requireSameNetwork: true }));
afterAll(() => { server.stop(true); hub.stop(); });
test('a phone on another network cannot pair', async () => {
const host = client({ role: 'host', secret: 'b'.repeat(40), name: 'TV' });
const { code } = await host.next('hello');
ipFor = () => '198.51.100.7';
const r = await pair(code);
expect(r.status).toBe(403);
ipFor = () => '203.0.113.1';
expect((await pair(code)).status).toBe(200);
host.ws.close();
});
});

View File

@@ -41,6 +41,8 @@ import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getP
import { createMediaCache, HIGH, LOW } from './media-cache.js'; import { createMediaCache, HIGH, LOW } from './media-cache.js';
import * as notesDb from './db.js'; import * as notesDb from './db.js';
import { registerNoteRoutes } from './notes.js'; import { registerNoteRoutes } from './notes.js';
import { createRemoteHub } from './remote.js';
import QRCode from 'qrcode';
import { dirname, join as pathJoin } from 'node:path'; import { dirname, join as pathJoin } from 'node:path';
// A media proxy must not die because one client's stream hit an edge case // A media proxy must not die because one client's stream hit an edge case
@@ -1557,6 +1559,37 @@ app.get('/api/user/data', async (c) => {
} }
}); });
// ============================================================================
// Phone remote for a desktop instance — see remote.js
// ============================================================================
const remote = createRemoteHub({ requireSameNetwork: process.env.REMOTE_SAME_NETWORK === '1' });
// The public IP a request came from. Behind Traefik that is the first
// X-Forwarded-For hop; locally, the socket address.
function clientIpOf(req, server) {
const xff = (req.headers.get('x-forwarded-for') || '').split(',')[0].trim();
if (xff) return xff;
try { return server?.requestIP?.(req)?.address || ''; } catch { return ''; }
}
app.post('/api/remote/pair', async (c) => {
let body = {};
try { body = await c.req.json(); } catch { /* validated below */ }
const r = remote.pair({ code: body.code, name: body.name, ip: clientIpOf(c.req.raw, c.env) });
return c.json(r.body, r.status);
});
// QR for the pairing link, drawn server-side so the client needs no library.
// Only ever encodes this origin's own /?pair=<6 digits> URL.
app.get('/api/remote/qr/:code', async (c) => {
const code = c.req.param('code');
if (!/^\d{6}$/.test(code)) return c.text('bad code', 400);
const proto = c.req.header('x-forwarded-proto') || new URL(c.req.url).protocol.replace(':', '');
const host = c.req.header('x-forwarded-host') || c.req.header('host') || new URL(c.req.url).host;
const svg = await QRCode.toString(`${proto}://${host}/?pair=${code}`, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' });
return c.body(svg, 200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-store' });
});
// ============================================================================ // ============================================================================
// Shared lyrics + chapters, API tokens, admin page — see notes.js // Shared lyrics + chapters, API tokens, admin page — see notes.js
// ============================================================================ // ============================================================================
@@ -1645,7 +1678,12 @@ async function main() {
// Bun.serve is the native Bun HTTP server // Bun.serve is the native Bun HTTP server
Bun.serve({ Bun.serve({
port: PORT, port: PORT,
fetch: app.fetch, // /ws/remote is upgraded here, before Hono — see remote.js.
fetch(req, server) {
if (new URL(req.url).pathname === '/ws/remote') return remote.upgrade(req, server, clientIpOf(req, server));
return app.fetch(req, server);
},
websocket: remote.websocket,
// Default is 10s, which killed /api/download proxy streams whenever the // Default is 10s, which killed /api/download proxy streams whenever the
// connection went idle mid-transfer. 240s then killed every save whose // connection went idle mid-transfer. 240s then killed every save whose
// server-side yt-dlp phase (no bytes sent yet) ran longer than 4 min — // server-side yt-dlp phase (no bytes sent yet) ran longer than 4 min —