/* 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 ? ` ` : host.replaced ? `` : !live ? `` : `
QR code to pair a phone
Pairing code
${host.code.slice(0, 3)} ${host.code.slice(3)}
Valid ${mins} more min · one use
On the phone: open this app → 📱 Remote → Use this phone as a remote, or scan the QR code.
Presenter / projector screen
QR code for the presenter screen
Lyrics-only display that follows this screen. On the projector's computer or TV browser open ${escapeHtml(location.origin)}/?present=${host.code} (same code), or scan the QR.
Connected phones & screens
${host.remotes.length ? host.remotes.map((r) => `
📱 ${escapeHtml(r.name)}
`).join('') : '
None yet.
'}`; const nameIn = body.querySelector('.rh-name'); if (document.activeElement !== nameIn) nameIn.value = cfg.name; body.querySelector('[data-act="start"]').classList.toggle('hidden', cfg.enabled && !host.replaced); body.querySelector('[data-act="stop"]').classList.toggle('hidden', !cfg.enabled); body.querySelector('[data-act="code"]').classList.toggle('hidden', !live); body.querySelector('[data-act="unpair"]').classList.toggle('hidden', !live); } 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 = `
`; 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) => `
â ż
${escapeHtml(v.title)}
${escapeHtml(v.channel)}
${v.duration ? clock(v.duration) : ''}
`).join('') : '
Nothing queued after this song.
'; } // ---- drag to reorder (pointer events, works for touch + mouse) ---- function onDragStart(e) { const handle = e.target.closest('.rv-handle'); if (!handle) return; const row = handle.closest('.rv-row'); e.preventDefault(); const rows = [...$('rvQueue').querySelectorAll('.rv-row')]; rc.drag = { row, rows, startY: e.clientY, from: Number(row.dataset.i), to: Number(row.dataset.i), h: row.offsetHeight }; row.classList.add('dragging'); row.setPointerCapture(e.pointerId); row.addEventListener('pointermove', onDragMove); row.addEventListener('pointerup', onDragEnd, { once: true }); row.addEventListener('pointercancel', onDragEnd, { once: true }); } function onDragMove(e) { const d = rc.drag; if (!d) return; const dy = e.clientY - d.startY; d.row.style.transform = `translateY(${dy}px)`; const pos = d.rows.indexOf(d.row); const target = Math.max(0, Math.min(d.rows.length - 1, pos + Math.round(dy / d.h))); d.to = Number(d.rows[target].dataset.i); } function onDragEnd() { const d = rc.drag; rc.drag = null; if (!d) return; d.row.removeEventListener('pointermove', onDragMove); d.row.classList.remove('dragging'); d.row.style.transform = ''; if (d.to !== d.from) { // Optimistic: reorder locally now, the host's queue push confirms it. const items = rc.queue.items.slice(); const [v] = items.splice(d.from, 1); items.splice(d.to, 0, v); rc.queue = { ...rc.queue, items }; renderQueue(); cmd('move', { from: d.from, to: d.to }); } } // ---- add to queue: search sheet ---- function openAddSheet() { const body = document.createElement('div'); body.className = 'rv-add'; body.innerHTML = `
`; const results = body.querySelector('.rv-add-results'); let found = []; body.querySelector('form').addEventListener('submit', async (e) => { e.preventDefault(); const q = body.querySelector('.rv-add-q').value.trim(); if (!q) return; results.innerHTML = '
Searching…
'; try { const res = await API.search(q); found = (res && res.ok && res.results) || []; } catch { found = []; } results.innerHTML = found.length ? found.slice(0, 20).map((v, i) => ` `).join('') : '
No results.
'; }); results.addEventListener('click', (e) => { const b = e.target.closest('[data-k]'); if (!b) return; const v = found[Number(b.dataset.k)]; if (!v) return; cmd('add', { video: { id: v.id, title: v.title, channel: v.channel, duration: v.duration, thumbnail: v.thumbnail } }); b.classList.add('added'); toast(`Added “${v.title}”`); }); showModal('+ Add to the screen’s queue', body, [{ label: 'Done', primary: true, onClick: closeModal }]); setTimeout(() => body.querySelector('.rv-add-q').focus(), 50); } async function doPair(codeRaw) { const code = String(codeRaw || '').replace(/\D/g, ''); const name = $('rvName').value.trim() || guessName('Phone'); if (code.length !== 6) { $('rvPairMsg').textContent = 'Enter the 6-digit code from the screen.'; return; } $('rvPairMsg').textContent = 'Pairing…'; try { const res = await fetch('/api/remote/pair', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, name }), }); const j = await res.json().catch(() => null); if (!j || !j.ok) { $('rvPairMsg').textContent = '⚠ ' + ((j && j.error) || `HTTP ${res.status}`); return; } save(PAIR_KEY, { roomId: j.roomId, remoteId: j.remoteId, token: j.token, hostName: j.hostName, name: j.name }); $('rvCode').value = ''; rcConnect(); } catch { $('rvPairMsg').textContent = '⚠ Network error — try again'; } } 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 = ``; 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 }; })();