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:
21
CLAUDE.md
21
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
|
||||
`<origin>/?pair=<code>`; the app consumes and strips `?pair=` at boot.
|
||||
|
||||
## Local dev
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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.
|
||||
|
||||
657
frontend/app.js
657
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 ? `
|
||||
<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() {
|
||||
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();
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
<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="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>
|
||||
|
||||
<div class="pl-header">
|
||||
@@ -362,6 +363,62 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<button class="bottom-nav-btn active" data-view="search" aria-label="Search">
|
||||
<span class="bottom-nav-icon">🔍</span>
|
||||
|
||||
@@ -3605,3 +3605,113 @@ html[data-layout="bento-hub"] .mini-bar-inner {
|
||||
.lyr-auto { margin-left: auto; }
|
||||
.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; }
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
"scripts": {
|
||||
"start": "bun 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": {
|
||||
"hono": "^4.7.10",
|
||||
"@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
256
server/remote.js
Normal 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
153
server/remote.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,8 @@ import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getP
|
||||
import { createMediaCache, HIGH, LOW } from './media-cache.js';
|
||||
import * as notesDb from './db.js';
|
||||
import { registerNoteRoutes } from './notes.js';
|
||||
import { createRemoteHub } from './remote.js';
|
||||
import QRCode from 'qrcode';
|
||||
import { dirname, join as pathJoin } from 'node:path';
|
||||
|
||||
// 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
|
||||
// ============================================================================
|
||||
@@ -1645,7 +1678,12 @@ async function main() {
|
||||
// Bun.serve is the native Bun HTTP server
|
||||
Bun.serve({
|
||||
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
|
||||
// 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 —
|
||||
|
||||
Reference in New Issue
Block a user