Load remote UI on intent and preserve synchronous host hooks
This commit is contained in:
@@ -38,3 +38,13 @@ test('eager shell and Settings definitions load before app state and defer readi
|
|||||||
const views=readFileSync(join(__dirname,'views-core.js'),'utf8');
|
const views=readFileSync(join(__dirname,'views-core.js'),'utf8');
|
||||||
assert.ok(views.includes('async function renderSettings()'));
|
assert.ok(views.includes('async function renderSettings()'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('remote facade preserves synchronous no-session and loaded changed hooks',()=>{
|
||||||
|
const source=app.match(/\/\* Remote facade \*\/([\s\S]*?)\/\* End remote facade \*\//)?.[1];
|
||||||
|
assert.ok(source,'remote facade is present');
|
||||||
|
const context=require('node:vm').createContext({window:{},Lazy:{proxy:()=>({}),onLoad(){}}});
|
||||||
|
require('node:vm').runInContext(source+';globalThis.api=Remote;',context);
|
||||||
|
assert.equal(context.api.changed(),undefined);
|
||||||
|
let calls=0;context.window.YT={Remote:{changed:()=>{calls++;return 'sync';}}};
|
||||||
|
assert.equal(context.api.changed(),'sync');assert.equal(calls,1);
|
||||||
|
});
|
||||||
|
|||||||
693
frontend/app.js
693
frontend/app.js
@@ -4719,683 +4719,24 @@ const Notes = (() => {
|
|||||||
// Remote = this phone drives a host. Hosting survives reloads (the secret and
|
// 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
|
// "enabled" flag live in localStorage), so a TV PC that restarts its browser
|
||||||
// keeps its phones paired.
|
// keeps its phones paired.
|
||||||
const Remote = (() => {
|
/* Remote facade */
|
||||||
const noop = { boot() {}, changed() {} };
|
const Remote = {
|
||||||
if (!WEB || typeof WebSocket === 'undefined') return noop;
|
...Lazy.proxy('feature:remote-client', ['openChooser', 'openHostModal'], 'Remote'),
|
||||||
|
changed: () => window.YT?.Remote?.changed(),
|
||||||
|
boot() {
|
||||||
|
if (!WEB) return;
|
||||||
|
$('navRemoteBtn').addEventListener('click', () => { closeSidebar(); Remote.openChooser().catch(error => toast(error.message)); });
|
||||||
|
$('remoteChip').addEventListener('click', () => Remote.openHostModal().catch(error => toast(error.message)));
|
||||||
|
Lazy.intent($('navRemoteBtn'), 'feature:remote-client');
|
||||||
|
Lazy.intent($('remoteChip'), 'feature:remote-client');
|
||||||
|
let configured = false;
|
||||||
|
try { configured = !!JSON.parse(localStorage.getItem('ytpRemoteHost') || 'null')?.enabled; } catch {}
|
||||||
|
if (configured || new URLSearchParams(location.search).has('pair')) Lazy.load('feature:remote-client').catch(error => toast(error.message));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Lazy.onLoad('feature:remote-client', () => window.YT.Remote.boot());
|
||||||
|
/* End remote facade */
|
||||||
|
|
||||||
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() {
|
|
||||||
$('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) }));
|
|
||||||
// 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 };
|
|
||||||
})();
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Playlist transitions: normal · gapless · crossfade
|
// Playlist transitions: normal · gapless · crossfade
|
||||||
|
|||||||
@@ -324,6 +324,14 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"/presenter.js"
|
"/presenter.js"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"feature:remote-client": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"background": true,
|
||||||
|
"files": [
|
||||||
|
"/remote-client.js"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="preload" href="fonts/BricolageGrotesque-latin.woff2" as="font" type="font/woff2" crossorigin />
|
<link rel="preload" href="fonts/BricolageGrotesque-latin.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
<link rel="preload" href="fonts/HankenGrotesk-latin.woff2" as="font" type="font/woff2" crossorigin />
|
<link rel="preload" href="fonts/HankenGrotesk-latin.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
<script type="application/json" id="ytp-assets">{"buildTag":"static","groups":{"core":{"contract":1,"eager":true,"files":["/index.html","/styles.css","/service-ui.css","/refresh.css","/layout-base.css","/recommendations.css","/flag-ui.js","/async-guard.js","/visible-timer.js","/asset-sync-core.js","/sw-update.js","/fingerprint.js","/opfs.js","/opfs-worker.js","/lyrics-core.js","/lyrics-display.js","/sha256.js","/resume-core.js","/loudness-wasm.js","/loudness.js","/sha256-wasm.js","/settings-sections.js","/related-videos.js","/related-videos.css","/audio-continuity.js","/fullscreen-orientation.js","/fullscreen-orientation.css","/download-actions.js","/download-actions.css","/download-errors.js","/downloads.js","/downloads-page.js","/offline-pages.css","/saved-page.js","/server-backfill.js","/saved-page.css","/prewarm-next.js","/settings-sections.css","/app.js","/manifest.webmanifest","/icons/icon-192.png","/icons/icon-512.png","/fonts/fonts.css","/fonts/BricolageGrotesque-latin-ext.woff2","/fonts/BricolageGrotesque-latin.woff2","/fonts/HankenGrotesk-latin-ext.woff2","/fonts/HankenGrotesk-latin.woff2","/fonts/JetBrainsMono-latin-ext.woff2","/fonts/JetBrainsMono-latin.woff2","/lazy.js","/feature-bootstrap.js","/eq-parameters.js","/assets.json","/device-db.js","/shell-core.js","/views-core.js","/section-rail.js"],"background":false},"layout:glass-stage":{"contract":1,"eager":false,"files":["/theme-glass.css","/theme-glass-panel.css","/glass-panel-layout.js"],"background":true,"dependencies":["feature:layout-controls"]},"layout:classic":{"contract":1,"eager":false,"files":["/theme-classic.css"],"background":true},"layout:bento-hub":{"contract":1,"eager":false,"files":["/theme-bento-hub.css","/bento-hub.js","/fonts/PlusJakartaSans-latin.woff2","/fonts/PlusJakartaSans-latin-ext.woff2"],"background":true,"dependencies":["feature:layout-controls"]},"layout:f7-swipe":{"contract":1,"eager":false,"files":["/theme-f7.css","/vendor/framework7-swipe.min.js","/f7-layout.js"],"background":true},"feature:piano":{"contract":1,"eager":false,"files":["/piano-core.js","/piano.js","/piano-engine.mjs"],"background":true},"feature:midi":{"contract":1,"eager":false,"files":["/midi.js"],"background":true},"feature:party-dj":{"contract":1,"eager":false,"files":["/party-dj.js"],"background":true},"feature:lyrics-window":{"contract":1,"eager":false,"files":["/lyrics-window.js"],"background":true},"feature:lower-third":{"contract":1,"eager":false,"files":["/lower-third.js","/lower-third.css"],"background":true},"feature:vocal-reducer":{"contract":1,"eager":false,"files":["/vocal-reducer.js"],"background":true},"feature:eq-core":{"contract":1,"eager":false,"files":["/eq-core.js"],"background":true},"feature:setlist-import":{"contract":1,"eager":false,"files":["/setlist-import.js"],"background":true},"feature:car-mode":{"contract":1,"eager":false,"files":["/car-mode.js","/car-mode.css"],"background":true},"feature:p2p":{"contract":1,"eager":false,"files":["/p2p-core.js","/p2p-client.js","/p2p-transfer.js","/p2p-recv-worker.js","/hash-worker.js"],"background":true},"feature:direct":{"contract":1,"eager":false,"files":["/direct-protocol.js","/direct-stream.js","/direct-media.js","/direct-media.css","/direct-recv-worker.js"],"background":true,"dependencies":["feature:p2p"]},"feature:video-edit":{"contract":1,"eager":false,"files":["/video-edit.js"],"background":true},"feature:stats":{"contract":1,"eager":false,"files":["/stats-core.js"],"background":true},"feature:export":{"contract":1,"eager":false,"files":["/export.js"],"background":true},"feature:playlist":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js"],"background":false},"feature:settings":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js","/settings-sections.js","/settings-sections.css","/views-core.js"],"background":false},"feature:service-mode":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js","/service-ui.css"],"background":false},"feature:lyrics-editor":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js","/lyrics-core.js","/lyrics-display.js"],"background":false},"feature:layout-controls":{"contract":1,"eager":false,"background":true,"files":["/theme-glass-controls.css","/glass-controls.js"]},"layout:sanctuary-stage":{"contract":1,"eager":false,"background":true,"files":[]},"layout:liturgical":{"contract":1,"eager":false,"background":true,"files":[]},"layout:living-hymnal":{"contract":1,"eager":false,"background":true,"files":[]},"layout:broadcaster":{"contract":1,"eager":false,"background":true,"files":[]},"layout:contemplative":{"contract":1,"eager":false,"background":true,"files":[]},"feature:presenter":{"contract":1,"eager":false,"background":true,"files":["/presenter.js"]}},"files":{}}</script>
|
<script type="application/json" id="ytp-assets">{"buildTag":"static","groups":{"core":{"contract":1,"eager":true,"files":["/index.html","/styles.css","/service-ui.css","/refresh.css","/layout-base.css","/recommendations.css","/flag-ui.js","/async-guard.js","/visible-timer.js","/asset-sync-core.js","/sw-update.js","/fingerprint.js","/opfs.js","/opfs-worker.js","/lyrics-core.js","/lyrics-display.js","/sha256.js","/resume-core.js","/loudness-wasm.js","/loudness.js","/sha256-wasm.js","/settings-sections.js","/related-videos.js","/related-videos.css","/audio-continuity.js","/fullscreen-orientation.js","/fullscreen-orientation.css","/download-actions.js","/download-actions.css","/download-errors.js","/downloads.js","/downloads-page.js","/offline-pages.css","/saved-page.js","/server-backfill.js","/saved-page.css","/prewarm-next.js","/settings-sections.css","/app.js","/manifest.webmanifest","/icons/icon-192.png","/icons/icon-512.png","/fonts/fonts.css","/fonts/BricolageGrotesque-latin-ext.woff2","/fonts/BricolageGrotesque-latin.woff2","/fonts/HankenGrotesk-latin-ext.woff2","/fonts/HankenGrotesk-latin.woff2","/fonts/JetBrainsMono-latin-ext.woff2","/fonts/JetBrainsMono-latin.woff2","/lazy.js","/feature-bootstrap.js","/eq-parameters.js","/assets.json","/device-db.js","/shell-core.js","/views-core.js","/section-rail.js"],"background":false},"layout:glass-stage":{"contract":1,"eager":false,"files":["/theme-glass.css","/theme-glass-panel.css","/glass-panel-layout.js"],"background":true,"dependencies":["feature:layout-controls"]},"layout:classic":{"contract":1,"eager":false,"files":["/theme-classic.css"],"background":true},"layout:bento-hub":{"contract":1,"eager":false,"files":["/theme-bento-hub.css","/bento-hub.js","/fonts/PlusJakartaSans-latin.woff2","/fonts/PlusJakartaSans-latin-ext.woff2"],"background":true,"dependencies":["feature:layout-controls"]},"layout:f7-swipe":{"contract":1,"eager":false,"files":["/theme-f7.css","/vendor/framework7-swipe.min.js","/f7-layout.js"],"background":true},"feature:piano":{"contract":1,"eager":false,"files":["/piano-core.js","/piano.js","/piano-engine.mjs"],"background":true},"feature:midi":{"contract":1,"eager":false,"files":["/midi.js"],"background":true},"feature:party-dj":{"contract":1,"eager":false,"files":["/party-dj.js"],"background":true},"feature:lyrics-window":{"contract":1,"eager":false,"files":["/lyrics-window.js"],"background":true},"feature:lower-third":{"contract":1,"eager":false,"files":["/lower-third.js","/lower-third.css"],"background":true},"feature:vocal-reducer":{"contract":1,"eager":false,"files":["/vocal-reducer.js"],"background":true},"feature:eq-core":{"contract":1,"eager":false,"files":["/eq-core.js"],"background":true},"feature:setlist-import":{"contract":1,"eager":false,"files":["/setlist-import.js"],"background":true},"feature:car-mode":{"contract":1,"eager":false,"files":["/car-mode.js","/car-mode.css"],"background":true},"feature:p2p":{"contract":1,"eager":false,"files":["/p2p-core.js","/p2p-client.js","/p2p-transfer.js","/p2p-recv-worker.js","/hash-worker.js"],"background":true},"feature:direct":{"contract":1,"eager":false,"files":["/direct-protocol.js","/direct-stream.js","/direct-media.js","/direct-media.css","/direct-recv-worker.js"],"background":true,"dependencies":["feature:p2p"]},"feature:video-edit":{"contract":1,"eager":false,"files":["/video-edit.js"],"background":true},"feature:stats":{"contract":1,"eager":false,"files":["/stats-core.js"],"background":true},"feature:export":{"contract":1,"eager":false,"files":["/export.js"],"background":true},"feature:playlist":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js"],"background":false},"feature:settings":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js","/settings-sections.js","/settings-sections.css","/views-core.js"],"background":false},"feature:service-mode":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js","/service-ui.css"],"background":false},"feature:lyrics-editor":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js","/lyrics-core.js","/lyrics-display.js"],"background":false},"feature:layout-controls":{"contract":1,"eager":false,"background":true,"files":["/theme-glass-controls.css","/glass-controls.js"]},"layout:sanctuary-stage":{"contract":1,"eager":false,"background":true,"files":[]},"layout:liturgical":{"contract":1,"eager":false,"background":true,"files":[]},"layout:living-hymnal":{"contract":1,"eager":false,"background":true,"files":[]},"layout:broadcaster":{"contract":1,"eager":false,"background":true,"files":[]},"layout:contemplative":{"contract":1,"eager":false,"background":true,"files":[]},"feature:presenter":{"contract":1,"eager":false,"background":true,"files":["/presenter.js"]},"feature:remote-client":{"contract":1,"eager":false,"background":true,"files":["/remote-client.js"]}},"files":{}}</script>
|
||||||
<script src="lazy.js"></script>
|
<script src="lazy.js"></script>
|
||||||
<link rel="stylesheet" href="fonts/fonts.css" />
|
<link rel="stylesheet" href="fonts/fonts.css" />
|
||||||
<link rel="preconnect" href="https://i.ytimg.com" crossorigin />
|
<link rel="preconnect" href="https://i.ytimg.com" crossorigin />
|
||||||
|
|||||||
673
frontend/remote-client.js
Normal file
673
frontend/remote-client.js
Normal file
@@ -0,0 +1,673 @@
|
|||||||
|
/* 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 };
|
||||||
|
})();
|
||||||
@@ -141,6 +141,7 @@ const SHELL = [
|
|||||||
'/views-core.js',
|
'/views-core.js',
|
||||||
'/section-rail.js',
|
'/section-rail.js',
|
||||||
'/presenter.js',
|
'/presenter.js',
|
||||||
|
'/remote-client.js',
|
||||||
'/app.js',
|
'/app.js',
|
||||||
'/manifest.webmanifest',
|
'/manifest.webmanifest',
|
||||||
'/icons/icon-192.png',
|
'/icons/icon-192.png',
|
||||||
|
|||||||
Reference in New Issue
Block a user