Files
ytplayer/frontend/remote-client.js

674 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Lazy classic chunk; its executing singleton stays pinned until reload. */
(window.YT ||= {}).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) {
DirectMedia.attach('remote-host', { send: hostSend, peers: () => host.remotes.map(r => ({ id: r.rid, name: r.name })) });
if (m.type === 'direct') { DirectMedia.handle('remote-host', m); return; }
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 {
directAvailable: !!meta && cachedIds.has(meta.id) && DirectMedia.enabled() && data.settings.p2pShare !== false,
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,
// For the presenter screen: this screen's personal lyric offset for the
// song and the playback speed (to extrapolate between updates).
off: meta && data.lyricOffsets ? Number(data.lyricOffsets[meta.id]) || 0 : 0,
rate: (M && M.playbackRate) || 1,
};
}
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();
syncMuteIcon();
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' || !isMediaId(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">Presenter / projector screen</div>
<div class="rh-present">
<img class="rh-qr rh-qr-sm" alt="QR code for the presenter screen" src="/api/remote/qr/${host.code}?kind=present" />
<div>
<div class="rh-sub">Lyrics-only display that follows this screen. On the projector's computer or TV browser open
<code>${escapeHtml(location.origin)}/?present=${host.code}</code> (same code), or scan the QR.</div>
<button class="np-btn" type="button" data-act="present">🖥 Open presenter window here</button>
<button class="np-btn" type="button" data-act="obs">Copy OBS browser-source URL</button>
</div>
</div>
<div class="rh-label">Connected phones & screens</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);
}
async function openHostModal() {
try { await Lazy.load('feature:direct'); } catch(error) { toast(error.message); return; }
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();
else if (act.dataset.act === 'obs') { navigator.clipboard.writeText(LowerThird.url(location.origin, host.code)).then(() => toast('OBS URL copied'), () => toast('Copy blocked')); }
else if (act.dataset.act === 'present') {
// A second window on this computer (drag it to the projector display).
window.open(`/?present=${host.code}`, 'ytp-presenter', 'popup,width=1280,height=720');
}
});
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, seekTimer: 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) {
DirectMedia.attach('remote-client', { send: rcSend, peers: () => [{ id: 'host', name: 'Paired screen', files: rc.state?.directAvailable && rc.state?.v ? [rc.state.v.id] : [] }] });
if (m.type === 'direct') { DirectMedia.handle('remote-client', m); return; }
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 : '';
let directPlay = $('rvDirectPlay');
if (!directPlay) { directPlay = document.createElement('button'); directPlay.id = 'rvDirectPlay'; directPlay.className = 'np-btn'; directPlay.style.minHeight = '44px'; directPlay.textContent = 'Play here from paired screen'; $('rvChannel').after(directPlay); }
directPlay.hidden = !v || !s.directAvailable || !DirectMedia.enabled();
directPlay.onclick = () => v && playVideoAt({ ...v, duration: s.dur || 0 }, liveCur());
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" decoding="async" />
<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';
}
}
async function openRemoteView({ code = '' } = {}) {
try { await Lazy.load('feature:direct'); } catch(error) { toast(error.message); return; }
rc.open = true;
rc.seekTimer = VisibleTimer.setIntervalWhenVisible(renderSeek, 500);
// 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;
rc.seekTimer?.clear();
rc.seekTimer = null;
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() {
$('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) }));
// 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, openChooser, openHostModal };
})();