Orbit Companion phase 1 (from release 0.108.75)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-06 21:11:38 +00:00
co-authored by Claude Opus 5.5
parent 973ad71a62
commit 7ec5dfcee9
10 changed files with 506 additions and 16 deletions
@@ -0,0 +1,239 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><title>Orbit</title>
<meta name="theme-color" content="#071632">
<style>
/* Orbit Companion (design/companion v2): the TV's look, filled in from /api/now. Orbit Night until then. */
:root{--bg:#071632;--surface:#12356B;--raised:#194C91;--deep:#0C2148;--accent:#FFDE59;--on-accent:#071632;--ink:#FFFFFF;--muted:#B7CBE8;--mint:#7EE0A8;--sky:#7FD1FF}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.35 system-ui,-apple-system,"Segoe UI",sans-serif}
button{font:inherit;color:inherit;border:0;background:none;cursor:pointer}
main{max-width:460px;margin:0 auto;padding:16px 16px 96px}
header{display:flex;align-items:center;gap:8px;margin:4px 0 14px}
.brand{font-weight:800;letter-spacing:.14em;font-size:18px}.brand svg{vertical-align:-4px;margin-right:6px}
.page{color:var(--muted);font-size:16px}
.room{display:flex;align-items:center;gap:10px;background:var(--surface);border-radius:14px;padding:10px 14px;margin-bottom:12px;font-size:13px}
.dot{width:8px;height:8px;border-radius:50%;background:var(--mint);flex:none}.dot.off{background:var(--muted);opacity:.5}
.room b{font-size:14px}.room span{color:var(--muted)}
.card{background:var(--surface);border-radius:16px;padding:14px}
.now{display:flex;gap:14px;align-items:center}
.tile{width:68px;height:68px;border-radius:12px;background:var(--raised);display:flex;align-items:center;justify-content:center;flex:none;font-weight:800;font-size:20px;color:var(--accent)}
.pill{display:inline-block;background:var(--accent);color:var(--on-accent);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:800;letter-spacing:.04em}
.pill.mint{background:var(--mint)}
.small{color:var(--muted);font-size:12px}.title{font-size:21px;font-weight:700;margin:2px 0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.grow{flex:1;min-width:0}
.bar{height:4px;border-radius:2px;background:var(--deep);margin:12px 0 10px;overflow:hidden}.bar i{display:block;height:100%;background:var(--accent)}
.next{font-size:12px;color:var(--muted)}.next b{color:var(--ink);font-weight:600;letter-spacing:.04em}
.pad{position:relative;width:240px;height:240px;margin:28px auto 22px;border-radius:50%;background:var(--surface)}
.pad button{position:absolute;width:64px;height:64px;display:flex;align-items:center;justify-content:center;border-radius:50%}
.pad button:active{background:var(--raised)}
.pad .up{left:88px;top:6px}.pad .down{left:88px;bottom:6px}.pad .left{left:6px;top:88px}.pad .right{right:6px;top:88px}
.pad .ok{left:70px;top:70px;width:100px;height:100px;background:var(--accent);color:var(--on-accent);font-weight:800;font-size:24px}
.pad .ok:active{filter:brightness(.9);background:var(--accent)}
.row{display:flex;gap:10px;align-items:center;justify-content:center}
.round{width:52px;height:52px;border-radius:50%;background:var(--surface);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:800;letter-spacing:.04em}
.rocker{display:flex;align-items:center;background:var(--surface);border-radius:999px;height:52px}
.rocker button{width:44px;height:52px;font-size:22px;font-weight:500}.rocker span{font-size:10px;font-weight:800;color:var(--muted);letter-spacing:.06em}
button:active{opacity:.75}
.link{display:block;margin:18px auto 0;background:var(--surface);border-radius:999px;padding:8px 18px;font-size:13px;font-weight:600}
.label{color:var(--muted);font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;margin:18px 0 8px}
.jumps{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
.jumps button{background:var(--raised);border-radius:12px;padding:12px 4px;font-size:12px;font-weight:600;display:flex;flex-direction:column;align-items:center;gap:6px}
.jumps svg{color:var(--accent)}
.line{display:flex;justify-content:space-between;align-items:center;background:var(--raised);border-radius:12px;padding:13px 14px;margin-bottom:8px;width:100%;text-align:left;font-weight:600}
.line span{color:var(--accent);font-size:13px}
.chips{display:flex;gap:8px;overflow-x:auto;margin-bottom:10px;padding-bottom:2px}
.chips button{background:var(--surface);border-radius:999px;padding:6px 14px;font-size:13px;font-weight:600;white-space:nowrap}
.chips button.on{background:var(--accent);color:var(--on-accent)}
.ch{display:flex;gap:12px;align-items:center;background:var(--surface);border-radius:14px;padding:11px 14px;margin-bottom:8px;width:100%;text-align:left}
.ch.on{background:var(--raised);outline:2px solid var(--accent)}
.ch .num{width:74px;flex:none}.ch .num b{display:block;color:var(--accent);font-size:15px}.ch .num small{color:var(--muted);font-size:11px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ch .what{font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ch .then{color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sheet{position:fixed;left:0;right:0;bottom:0;z-index:5;background:var(--deep);border-radius:22px 22px 0 0;padding:10px 18px calc(84px + env(safe-area-inset-bottom));box-shadow:0 -10px 40px #0008;transform:translateY(110%);transition:transform .2s ease}
.sheet.open{transform:none}.sheet .grab{width:40px;height:4px;border-radius:2px;background:var(--muted);opacity:.5;margin:0 auto 12px}
.sheet .inner{max-width:460px;margin:0 auto}
.btn{border-radius:999px;padding:12px 16px;font-weight:700;background:var(--raised);width:100%}.btn.gold{background:var(--accent);color:var(--on-accent)}
.shade{position:fixed;inset:0;background:#0006;z-index:4;display:none}.shade.open{display:block}
.cu{display:flex;gap:12px;align-items:center;background:var(--surface);border-radius:14px;padding:11px 14px;margin-bottom:8px;width:100%;text-align:left}
.cu .play{width:34px;height:34px;border-radius:50%;border:2px solid var(--accent);display:flex;align-items:center;justify-content:center;flex:none;color:var(--accent)}
.rm{background:var(--surface);border-radius:16px;padding:14px;margin-bottom:10px}.rm.here{outline:2px solid var(--accent)}
.rm h3{margin:0;font-size:17px;display:flex;justify-content:space-between;align-items:center}
.tags{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px}.tags span{background:var(--raised);border-radius:999px;padding:3px 10px;font-size:11px;font-weight:700}
a.btn{display:block;text-align:center;text-decoration:none;color:inherit;margin-top:10px}
nav{position:fixed;left:0;right:0;bottom:0;z-index:6;background:var(--bg);border-top:1px solid var(--surface);padding:6px 6px calc(6px + env(safe-area-inset-bottom))}
nav div{max-width:460px;margin:0 auto;display:flex}
nav button{flex:1;display:flex;flex-direction:column;align-items:center;gap:3px;padding:6px 0;font-size:11px;color:var(--muted)}
nav button i{display:flex;align-items:center;justify-content:center;width:46px;height:28px;border-radius:999px}
nav button.on{color:var(--accent);font-weight:700}nav button.on i{background:var(--surface)}
.toast{position:fixed;left:50%;bottom:90px;transform:translateX(-50%);background:var(--accent);color:var(--on-accent);padding:9px 16px;border-radius:999px;font-weight:700;font-size:13px;z-index:9;display:none;max-width:90vw;text-align:center}
.empty{color:var(--muted);text-align:center;padding:30px 10px}
</style></head><body>
<main id="app"></main>
<div class="shade" id="shade"></div><div class="sheet" id="sheet"><div class="grab"></div><div class="inner" id="sheetIn"></div></div>
<nav><div id="tabs"></div></nav>
<div class="toast" id="toast"></div>
<script>
const $ = s => document.querySelector(s);
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const api = async (path, body) => {
const r = await fetch(path, body ? {method:'POST', headers:{'Content-Type':'application/json','X-Orbit':'1'}, body:JSON.stringify(body)} : {cache:'no-store'});
if (r.headers.get('content-type')?.includes('text/html')) { location.reload(); throw new Error('Join again'); }
const j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || 'Something went wrong'); return j;
};
const toast = t => { const e = $('#toast'); e.textContent = t; e.style.display = 'block'; clearTimeout(toast.t); toast.t = setTimeout(() => e.style.display = 'none', 2200); };
const buzz = () => navigator.vibrate?.(12);
const hm = ms => new Date(ms).toLocaleTimeString('en-GB', {hour:'2-digit', minute:'2-digit', hourCycle:'h23'});
let skew = 0; const now = () => Date.now() + skew;
const left = p => Math.max(0, Math.round((p.endMs - now()) / 60000));
const I = {
remote:'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="7" y="2.5" width="10" height="19" rx="3"/><circle cx="12" cy="8" r="2"/><path d="M10 14h4M10 17h4"/></svg>',
guide:'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M9 9v11M15 9v11"/></svg>',
catchup:'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3.5 12a8.5 8.5 0 1 0 2.5-6"/><path d="M3 4v4h4M12 8v4l3 2"/></svg>',
rooms:'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="2.5" y="5" width="13" height="10" rx="1.5"/><path d="M6 19h6"/><rect x="17.5" y="9" width="4" height="10" rx="1"/></svg>',
more:'<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>',
chev:d => `<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="${{up:'M6 15l6-6 6 6',down:'M6 9l6 6 6-6',left:'M15 6l-6 6 6 6',right:'M9 6l6 6-6 6'}[d]}"/></svg>`,
back:'<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 7L4 12l5 5"/><path d="M4 12h10a6 6 0 0 1 0 12"/></svg>',
play:'<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M7 4l14 8-14 8z"/></svg>',
drivein:'<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M14 3a9 9 0 1 0 7 13A7 7 0 0 1 14 3z"/></svg>',
arcade:'<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l10 10-10 10L2 12z"/></svg>',
home:'<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11l9-7 9 7v9h-6v-6H9v6H3z"/></svg>',
mark:'<svg width="24" height="20" viewBox="0 0 24 20" fill="none"><path d="M4 16 C1 10 6 3 14 2 C9 5 6 10 8 16" fill="currentColor" opacity=".9"/><circle cx="18" cy="5" r="2.2" fill="var(--accent)"/></svg>'
};
const TABS = [['remote','Remote'],['guide','Guide'],['catchup','Catch Up'],['rooms','Rooms'],['more','More']];
let tab = (() => { try { return localStorage.getItem('orbit.tab') || 'remote'; } catch { return 'remote'; } })();
if (!TABS.some(t => t[0] === tab)) tab = 'remote';
let state = {now:null, guide:null, catchup:null, rooms:null, filter:'all'};
function look(l) {
if (!l) return; const r = document.documentElement.style;
const map = {bg:l.background, surface:l.surface, raised:l.raised, deep:l.deep, accent:l.accent, 'on-accent':l.onAccent, ink:l.ink, muted:l.muted, mint:l.mint, sky:l.sky};
for (const [k, v] of Object.entries(map)) if (v) r.setProperty('--' + k, v);
document.querySelector('meta[name=theme-color]').content = l.background;
}
function tabs() {
$('#tabs').innerHTML = TABS.map(([k, n]) => `<button class="${k === tab ? 'on' : ''}" data-tab="${k}"><i>${I[k]}</i>${n}</button>`).join('');
}
function head(page) {
const n = state.now; const ch = n?.channel;
const where = !n ? 'Connecting…' : ch ? `${n.watching ? 'Watching' : 'On'} ${ch.number} ${esc(ch.name)}` : 'Orbit is open';
return `<header><span class="brand">${I.mark}ORBIT</span><span class="page">${page}</span></header>
<div class="room"><span class="dot ${n ? '' : 'off'}"></span><b>${esc(n?.room || 'This TV')}</b><span>${where}</span></div>`;
}
function nowCard() {
const n = state.now; if (!n?.channel) return `<div class="card"><div class="small">Nothing's tuned yet. Pick a channel in the Guide.</div></div>`;
const p = n.now, pct = Math.min(100, Math.max(0, (now() + (n.catchingUp ? 0 : 0) - p.startMs) / (p.endMs - p.startMs) * 100));
const tag = n.catchingUp ? '<span class="pill mint">CATCH UP</span>' : '<span class="pill">LIVE</span>';
return `<div class="card"><div class="now"><div class="tile">${n.channel.number}</div><div class="grow">
<div class="small">${tag} &nbsp;${n.channel.number} ${esc(n.channel.name)}</div>
<div class="title">${esc(p.title)}</div>
<div class="small">${p.sub ? esc(p.sub) + ' · ' : ''}${n.catchingUp ? 'catching up' : left(p) + ' min left · ends ' + hm(p.endMs)}</div></div></div>
${n.catchingUp ? '' : `<div class="bar"><i style="width:${pct}%"></i></div>`}
<div class="next"><b>NEXT</b> ${n.catchingUp ? '' : hm(n.next.startMs)} ${esc(n.next.title)}</div></div>`;
}
function remote() {
return head('Remote') + nowCard() + `
<div class="pad"><button class="up" data-key="up" aria-label="Up">${I.chev('up')}</button><button class="left" data-key="left" aria-label="Left">${I.chev('left')}</button>
<button class="ok" data-key="ok">OK</button><button class="right" data-key="right" aria-label="Right">${I.chev('right')}</button><button class="down" data-key="down" aria-label="Down">${I.chev('down')}</button></div>
<div class="row"><button class="round" data-key="back" aria-label="Back">${I.back}</button>
<div class="rocker"><button data-ch="1" aria-label="Channel up">+</button><span>CH</span><button data-ch="-1" aria-label="Channel down">−</button></div>
<div class="rocker"><button data-vol="down" aria-label="Volume down">−</button><span>VOL</span><button data-vol="up" aria-label="Volume up">+</button></div>
<button class="round" data-vol="mute">MUTE</button></div>
<button class="link" data-sheet="more">More controls</button>`;
}
function moreControls() {
return `<div class="label">Jump to</div><div class="jumps">
<button data-go="guide">${I.guide}Guide</button><button data-go="catchup">${I.catchup}Catch Up</button>
<button data-go="drivein">${I.drivein}Drive-In</button><button data-go="arcade">${I.arcade}Arcade</button></div>
<div class="label">On the TV now</div>
<button class="line" data-key="right">Subtitles, sound and sleep <span>Opens on the TV</span></button>
<button class="line" data-key="playpause">Play or pause <span>Catch Up and films</span></button>
<button class="line" data-go="home">Home <span>Orbit's front page</span></button>`;
}
function guide() {
const g = state.guide; if (!g) return head('Guide') + '<div class="empty">Loading the guide…</div>';
const cats = ['all', ...new Set(g.channels.map(c => c.category).filter(Boolean))];
const f = cats.includes(state.filter) ? state.filter : 'all', list = g.channels.filter(c => f === 'all' || c.category === f);
return head('Guide') + `<div class="chips">${cats.map(k => `<button class="${k === f ? 'on' : ''}" data-filter="${esc(k)}">${k === 'all' ? 'All' : esc(k)}</button>`).join('')}</div>` +
(list.map(c => `<button class="ch ${c.number === g.selected ? 'on' : ''}" data-pick="${c.number}"><div class="num"><b>${c.number}</b><small>${esc(c.name)}</small></div>
<div class="grow"><div class="what">${esc(c.now.title)}</div><div class="then">${hm(c.now.startMs)}–${hm(c.now.endMs)} · then ${esc(c.next.title)}</div></div>
${c.number === g.selected && state.now?.watching ? '<span class="pill mint">ON TV</span>' : '<span class="pill">LIVE</span>'}</button>`).join('') || '<div class="empty">No channels here.</div>');
}
function pickSheet(n) {
const c = state.guide?.channels.find(c => c.number === n); if (!c) return '';
return `<div class="title">${esc(c.now.title)}</div><div class="small">${c.number} ${esc(c.name)} · Live now · ends ${hm(c.now.endMs)}${c.now.sub ? ' · ' + esc(c.now.sub) : ''}</div>
<div class="room" style="margin-top:12px"><span class="dot"></span>Watching on: <b>${esc(state.now?.room || 'this TV')}</b></div>
<button class="btn gold" data-tune="${c.number}">Watch on this TV</button>
<div class="small" style="margin-top:10px">Next at ${hm(c.next.startMs)}: ${esc(c.next.title)}</div>`;
}
function catchup() {
const c = state.catchup; if (!c) return head('Catch Up') + '<div class="empty">Loading Catch Up…</div>';
const shelves = c.shelves.filter(s => s.entries.length);
if (!shelves.length) return head('Catch Up') + '<div class="empty">Nothing to catch up on yet. Programmes your channels show appear here for two days.</div>';
return head('Catch Up') + shelves.map(s => `<div class="label">${esc(s.title)}</div>` + s.entries.map(e => `
<button class="cu" data-cu="${e.number}:${e.startMs}"><div class="grow"><div class="what" style="font-weight:700">${esc(e.title)}</div>
<div class="small">${e.sub ? esc(e.sub) + ' · ' : ''}${e.number} ${esc(e.channel)} · aired ${hm(e.startMs)}${e.continues ? ' · carries on' : ''}</div></div><span class="play">${I.play}</span></button>`).join('')).join('') +
'<div class="small" style="text-align:center;margin-top:12px">Plays from the start on this TV, and carries on as it aired.</div>';
}
function rooms() {
const r = state.rooms, n = state.now;
let h = head('Rooms') + '<div class="small" style="margin-bottom:10px">Every Orbit in the house, on this Wi-Fi.</div>';
h += `<div class="rm here"><h3>${esc(n?.room || 'This TV')}<span class="dot"></span></h3>
<div class="small">${n?.channel ? `${n.watching ? 'Watching' : 'On'} ${esc(n.now.title)} · ${n.channel.number} ${esc(n.channel.name)}` : 'Orbit open'}</div>
<div class="tags"><span>Online</span><span>This phone's TV</span></div></div>`;
if (!r) return h + '<div class="empty">Looking for other rooms…</div>';
h += r.online.map(o => `<div class="rm"><h3>${esc(o.name)}<span class="dot"></span></h3><div class="small">Orbit open</div>
<div class="tags"><span>Online</span></div><a class="btn" href="http://${esc(o.address)}:8099/">Open its remote</a>
<div class="small" style="margin-top:6px">Each TV connects a phone once, from its own Settings.</div></div>`).join('');
h += r.asleep.map(a => `<div class="rm"><h3>${esc(a)}<span class="dot off"></span></h3><div class="small">Orbit closed</div><div class="tags"><span>Asleep</span></div></div>`).join('');
if (!r.online.length && !r.asleep.length) h += '<div class="empty">No other rooms yet. Add one from Settings › Rooms and phones on the TV.</div>';
return h;
}
function more() {
return head('More') + `<div class="label">Your channels</div>
<a class="line" href="/create" style="text-decoration:none;color:inherit">Create a channel <span>Name, shows, style, hours</span></a>
<div class="label">Look</div><div class="card small">This phone wears the TV's look${state.now?.look ? ': <b style="color:var(--ink)">' + esc(state.now.look.name) + '</b>' : ''}. Change it on the TV in Settings › Look.</div>
<div class="label">About</div><div class="card small">Orbit Companion runs on the TV itself, over your home Wi-Fi. Nothing to install, no account. It works while Orbit is open on the TV.
To stop this phone, remove it in Settings › Rooms and phones.</div>`;
}
function render() {
tabs();
$('#app').innerHTML = ({remote, guide, catchup, rooms, more})[tab]();
}
function openSheet(html) { $('#sheetIn').innerHTML = html; $('#sheet').classList.add('open'); $('#shade').classList.add('open'); }
function closeSheet() { $('#sheet').classList.remove('open'); $('#shade').classList.remove('open'); }
$('#shade').onclick = closeSheet;
async function load(what) {
try {
if (what === 'now') { const n = await api('/api/now'); skew = n.serverMs - Date.now(); look(n.look); state.now = n; }
if (what === 'guide') state.guide = await api('/api/guide');
if (what === 'catchup') state.catchup = await api('/api/catchup');
if (what === 'rooms') state.rooms = await api('/api/rooms');
offline = false;
} catch (e) { if (!offline) toast(e.message === 'Failed to fetch' ? "Orbit isn't open on the TV" : e.message); offline = true; }
render();
}
let offline = false;
const send = (path, body, done) => { buzz(); api(path, body).then(j => { done?.(j); setTimeout(() => load('now'), 600); }).catch(e => toast(e.message === 'Failed to fetch' ? "Orbit isn't open on the TV" : e.message)); };
document.addEventListener('click', e => {
const b = e.target.closest('button,[data-tab]'); if (!b) return;
const d = b.dataset;
if (d.tab) { tab = d.tab; try { localStorage.setItem('orbit.tab', tab); } catch {} closeSheet(); render(); if (tab !== 'remote' && tab !== 'more') load(tab); return; }
if (d.key) send('/api/key', {key: d.key});
if (d.vol) send('/api/volume', {change: d.vol});
if (d.ch) send('/api/channel', {step: +d.ch});
if (d.sheet) openSheet(moreControls());
if (d.go) send('/api/section', {section: d.go}, () => { closeSheet(); toast('On the TV now'); });
if (d.filter) { state.filter = d.filter; render(); }
if (d.pick) openSheet(pickSheet(+d.pick));
if (d.tune) send('/api/tune', {number: +d.tune}, () => { closeSheet(); toast('Watching on the TV'); setTimeout(() => load('guide'), 700); });
if (d.cu) { const [number, startMs] = d.cu.split(':').map(Number); send('/api/catchup/watch', {number, startMs}, () => toast('Playing from the start on the TV')); }
});
render(); load('now'); if (tab !== 'remote' && tab !== 'more') load(tab);
// Kept fresh while the phone is looking: what's on every 15 seconds, and the open list every minute.
setInterval(() => { if (!document.hidden) load('now'); }, 15000);
setInterval(() => { if (!document.hidden && ['guide','catchup','rooms'].includes(tab)) load(tab); }, 60000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) load('now'); });
</script>
</body></html>
@@ -187,7 +187,7 @@ internal fun OrbitField(value: String, onChange: (String) -> Unit, hint: String,
* code and QR code are replaced by new ones on their own, until a phone has joined.
*/
@Composable
private fun PhoneCard(modifier: Modifier) {
internal fun PhoneCard(modifier: Modifier, title: String = "On your phone") {
var pairing by remember { mutableStateOf(PhoneLink.beginPairing()) }
val url = remember(pairing) { PhoneLink.pairingUrl(pairing) }
val qr = remember(url) { url?.let { qrBitmap(it, 360) } }
@@ -201,7 +201,7 @@ private fun PhoneCard(modifier: Modifier) {
}
}
Column(modifier.background(Panel, RoundedCornerShape(12.dp)).padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("On your phone", color = Ink, fontFamily = Anton, fontSize = 22.sp)
Text(title, color = Ink, fontFamily = Anton, fontSize = 22.sp)
if (url == null || qr == null) { Text("This TV isn't on a home Wi-Fi network, so a phone can't reach it.", color = Muted, fontSize = 12.sp); return@Column }
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) {
androidx.compose.foundation.Image(qr, "QR code for the phone page", Modifier.size(124.dp).background(Color.White, RoundedCornerShape(6.dp)).padding(6.dp))
@@ -501,6 +501,24 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
if (lockedOut(selectedNumber) || profileHides(selectedNumber)) { watchingChannel = false; channelClockOffset = 0; selectedNumber = availableChannels.firstOrNull()?.number ?: 101 }
}
val liveChannel = availableChannels.firstOrNull { it.number == selectedNumber }
fun tune(number: Int, offset: Long) {
if (number != selectedNumber) { lastNumber = selectedNumber; selectedNumber = number }
if (offset == 0L) liveHandoff.handOver(number)
channelClockOffset = offset; watchingChannel = true
}
// Orbit Companion: the channels this TV shows right now, and tuning as the guide does. Set here, before any screen
// returns early, so the phone works from every section (the Drive-In and the Arcade go to the guide to tune).
val shownChannels by rememberUpdatedState(availableChannels.filter { !lockedOut(it.number) && !profileHides(it.number) })
val companionTune by rememberUpdatedState<(Int, Long) -> Unit> { n, offset ->
if (shownChannels.any { it.number == n }) { tune(n, offset); if (!mainGuide && !mainHome && !mainWhatsOn && !mainCatchUp) onGuide() }
}
DisposableEffect(Unit) {
PhoneLink.screen = CompanionScreen(channels = { shownChannels }, selected = { selectedNumber }, watching = { watchingChannel }, offset = { channelClockOffset },
tune = { n, offset -> companionTune(n, offset) })
onDispose { PhoneLink.screen = null }
}
// The Companion's jump buttons: a section on the TV closes the programme being watched, as Back would.
LaunchedEffect(OrbitCompanion.asked) { if (OrbitCompanion.asked != null) { watchingChannel = false; watchingDriveIn = false } }
if (mainDriveIn && liveSession != null) {
val booked = driveInPlan
val onScreen = remember(booked) { booked?.let(::driveInChannel) }
@@ -536,11 +554,6 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
// Catch Up's replay clock: never ahead of live, and back at live it's the channel as normal.
onShift = { delta -> channelClockOffset = (channelClockOffset + delta).coerceAtMost(0) },
onLive = { channelClockOffset = 0 })
fun tune(number: Int, offset: Long) {
if (number != selectedNumber) { lastNumber = selectedNumber; selectedNumber = number }
if (offset == 0L) liveHandoff.handOver(number)
channelClockOffset = offset; watchingChannel = true
}
catchUpOpen?.let { entry ->
CatchUpCard(entry, System.currentTimeMillis(), onDismiss = { catchUpOpen = null }, onWatch = {
catchUpOpen = null
@@ -135,6 +135,8 @@ private fun OrbitApp() {
onDispose { lifecycle.removeObserver(observer) }
}
// Back on the main menu closes the app.
// Orbit Companion's jump buttons (Guide, Catch Up, Drive-In, Arcade): only once signed in, and the Kids rules below still apply.
LaunchedEffect(OrbitCompanion.asked) { OrbitCompanion.asked?.let { (wanted, _) -> if (realChannelAvailable && section != "Connect Emby") section = wanted } }
BackHandler(realChannelAvailable && section != "TV Guide" && section != "Home" && section != "What's On") { section = "Home" }
val navFocus = remember { FocusRequester() }
val driveInFocus = remember { FocusRequester() }
@@ -409,6 +409,9 @@ private fun AllSetStep(onStart: () -> Unit) {
Column(Modifier.align(Alignment.BottomCenter).padding(bottom = 30.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("You're on air", color = Ink, fontFamily = Anton, fontSize = 44.sp)
Text(if (count > 0) "$count channels are planning their first week. The Drive-In opens on Friday." else "Your channels are ready.", color = Muted, fontSize = 16.sp)
// Orbit Companion (Paul, 6 November 2026): mentioned once more at the end, and where to find it later.
Text(if (PhoneLink.phones.isNotEmpty()) "Your phone is connected: open it any time as a remote for this TV."
else "Use your phone as a remote too: Settings › Rooms and phones › Connect a phone, any time.", color = Muted, fontSize = 14.sp)
OrbitButton("Start watching", Modifier.width(240.dp).focusRequester(go), centred = true, onClick = onStart)
}
}
@@ -0,0 +1,112 @@
package tv.orbit.classic
import android.app.Instrumentation
import android.content.Context
import android.media.AudioManager
import android.view.KeyEvent
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.Executors
/**
* Orbit Companion, phase 1 (Paul, 6 November 2026, from design/companion v2): the phone page grows into a remote for the TV
* it was opened from. Remote (what's on, the d-pad, channels and volume), Guide (now and next, and Watch on this TV), Catch Up
* (the last two days, from the start) and Rooms (this TV and the others Orbit can see). The rules stay the phone link's:
* home network, a joined phone, titles and times only, and nothing runs unless Orbit is open on the TV.
* - The phone sees the channels this TV shows right now: a Kids profile's, and After Dark only while it's unlocked.
* - Buttons are pressed as if on the TV's own remote, so the TV does exactly what it would for the remote, and nothing more.
*/
internal class CompanionScreen(
/** The channels this TV shows right now, in guide order. */
val channels: () -> List<SampleChannel>,
val selected: () -> Int,
val watching: () -> Boolean,
/** Catch Up's replay clock: below zero while catching up. */
val offset: () -> Long,
/** Tune [number] from [offset] (zero is live), as the guide does. */
val tune: (number: Int, offset: Long) -> Unit)
internal object OrbitCompanion {
/** The buttons the phone may press, by the name the page uses. */
val keys = mapOf("up" to KeyEvent.KEYCODE_DPAD_UP, "down" to KeyEvent.KEYCODE_DPAD_DOWN, "left" to KeyEvent.KEYCODE_DPAD_LEFT,
"right" to KeyEvent.KEYCODE_DPAD_RIGHT, "ok" to KeyEvent.KEYCODE_DPAD_CENTER, "back" to KeyEvent.KEYCODE_BACK,
"playpause" to KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)
/** The places More's jump buttons go: the TV's own sections. */
val sections = mapOf("home" to "Home", "whatson" to "What's On", "guide" to "TV Guide", "catchup" to "Catch Up", "drivein" to "Drive-In", "arcade" to "Arcade")
/** A section the phone asked for, with a count so asking twice still moves the TV. Read by the app and the screen. */
var asked by mutableStateOf<Pair<String, Int>?>(null)
private set
fun open(section: String) { asked = section to ((asked?.second ?: 0) + 1) }
/** Presses go one at a time, in order, off the screen's thread (injecting waits for the TV to take each one). */
private val presser = Executors.newSingleThreadExecutor { Thread(it, "orbit-companion-keys").apply { isDaemon = true } }
internal var inject: (Int) -> Unit = { code -> Instrumentation().sendKeyDownUpSync(code) }
fun press(code: Int) { presser.execute { runCatching { inject(code) }.onFailure { android.util.Log.i("OrbitCompanion", "press refused") } } }
internal var volume: (Int) -> Unit = { direction ->
(PhoneLink.context()?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.adjustStreamVolume(AudioManager.STREAM_MUSIC, direction, AudioManager.FLAG_SHOW_UI)
}
val volumes = mapOf("up" to AudioManager.ADJUST_RAISE, "down" to AudioManager.ADJUST_LOWER, "mute" to AudioManager.ADJUST_TOGGLE_MUTE)
private fun hex(c: Color) = "#%06X".format(c.toArgb() and 0xFFFFFF)
/** The TV's look, so the phone wears the same colours. */
fun look(): JSONObject = OrbitTheme.palette.let { p ->
JSONObject().put("name", OrbitTheme.look.title).put("light", p.light).put("background", hex(p.background)).put("surface", hex(p.surface))
.put("raised", hex(p.raised)).put("deep", hex(p.deep)).put("accent", hex(p.accent)).put("onAccent", hex(p.onAccent))
.put("ink", hex(p.ink)).put("muted", hex(p.muted)).put("mint", hex(p.mint)).put("sky", hex(p.sky)).put("gold", hex(p.gold))
}
fun programme(b: SampleBroadcast): JSONObject = guideTitleParts(b.media.item.title).let { (title, sub) ->
JSONObject().put("title", title).put("sub", sub ?: JSONObject.NULL).put("startMs", b.startMs).put("endMs", b.endMs)
}
private fun channelJson(c: SampleChannel) = JSONObject().put("number", c.number).put("name", ChannelBrands.forNumber(c.number)?.displayName ?: c.name)
/** What the TV is on: the chosen channel, what it's showing at the TV's clock, and what's next. */
fun now(screen: CompanionScreen, nowMs: Long): JSONObject {
val reply = JSONObject().put("room", Rooms.name).put("look", look()).put("serverMs", nowMs)
val channel = screen.channels().firstOrNull { it.number == screen.selected() } ?: return reply.put("channel", JSONObject.NULL)
val at = nowMs + screen.offset()
val on = channel.at(at)
return reply.put("channel", channelJson(channel)).put("now", programme(on)).put("next", programme(channel.at(on.endMs)))
.put("watching", screen.watching()).put("catchingUp", screen.offset() < 0).put("positionMs", at - on.startMs)
}
/** Every channel's now and next. */
fun guide(screen: CompanionScreen, nowMs: Long): JSONObject = JSONObject().put("selected", screen.selected()).put("serverMs", nowMs).put("channels",
JSONArray(screen.channels().map { c -> val on = c.at(nowMs)
channelJson(c).put("category", ChannelBrands.forNumber(c.number)?.category?.label ?: JSONObject.NULL).put("now", programme(on)).put("next", programme(c.at(on.endMs))) }))
fun catchUp(screen: CompanionScreen, nowMs: Long): JSONArray = JSONArray(catchUpShelves(screen.channels(), nowMs).map { shelf ->
JSONObject().put("title", shelf.title).put("entries", JSONArray(shelf.entries.map { e ->
programme(e.show).put("number", e.channel.number).put("channel", e.channel.name).put("continues", e.continues) }))
})
/** The Catch Up programme the phone chose, only if it's still in Catch Up on a channel this TV shows. */
fun catchUpEntry(screen: CompanionScreen, number: Int, startMs: Long, nowMs: Long): CatchUpEntry? =
catchUpShelves(screen.channels(), nowMs).flatMap { it.entries }.firstOrNull { it.channel.number == number && it.show.startMs == startMs }
/** The channel [step] away from the chosen one, round the guide. */
fun stepped(screen: CompanionScreen, step: Int): Int? {
val list = screen.channels().ifEmpty { return null }
val at = list.indexOfFirst { it.number == screen.selected() }.coerceAtLeast(0)
return list[Math.floorMod(at + step, list.size)].number
}
// --- Rooms: the other TVs with Orbit open, found on the Wi-Fi while a phone is looking. ---
private var finder: RoomFinder? = null
@Synchronized fun rooms(context: Context?): JSONObject {
if (finder == null && context != null) finder = runCatching { RoomFinder(context).also { it.start() } }.getOrNull()
val found = finder?.found.orEmpty().filter { it.first != Rooms.name }
val asleep = Rooms.joined.map { it.first }.filter { r -> r != Rooms.name && found.none { it.first == r } }.distinct()
return JSONObject().put("here", Rooms.name)
.put("online", JSONArray(found.map { (name, ip) -> JSONObject().put("name", name).put("address", if (':' in ip) "[$ip]" else ip) }))
.put("asleep", JSONArray(asleep))
}
@Synchronized fun stopLooking() { finder?.stop(); finder = null }
}
@@ -78,6 +78,8 @@ internal object PhoneLink {
private fun needed() = phones.isNotEmpty() || pairing != null || roomOffer != null || listening
/** Put in front of [handler] for a while: the Welcome Tour's, which saves nothing. */
@Volatile var standIn: PhoneHandler? = null
/** The Companion's view of the TV (see [OrbitCompanion]): there in every section, unlike Create your own's [handler]. */
@Volatile var screen: CompanionScreen? = null
var pairing by mutableStateOf<Pairing?>(null)
private set
/** The phone that last did something, for the TV to say so. */
@@ -139,7 +141,7 @@ internal object PhoneLink {
}
}, "orbit-phone-link").apply { isDaemon = true }.start()
}
@Synchronized fun stop() { runCatching { server?.close() }; server = null }
@Synchronized fun stop() { runCatching { server?.close() }; server = null; OrbitCompanion.stopLooking() }
// --- HTTP, kept small: one request per connection, bounded sizes. ---
private class Request(val method: String, val path: String, val query: Map<String, String>, val headers: Map<String, String>, val body: String)
@@ -226,7 +228,8 @@ internal object PhoneLink {
when {
r.path == "/join" -> join(r, out, socket.inetAddress.hostAddress.orEmpty())
phone == null -> page(out, PhonePages.join(null))
r.path == "/" -> page(out, PhonePages.app())
r.path == "/" -> page(out, PhonePages.companion())
r.path == "/create" -> page(out, PhonePages.app())
r.method == "POST" && r.headers["x-orbit"] != "1" -> json(out, 403, JSONObject().put("error", "Refused"))
else -> api(r, out, phone)
}
@@ -283,8 +286,9 @@ internal object PhoneLink {
if (o.isNull("year")) null else o.optInt("year"), o.optString("type").takeIf { it == "Movie" || it == "Series" } ?: "Series", "", "Chosen for your channel")
private fun api(r: Request, out: OutputStream, phone: String) {
val h = standIn ?: handler ?: return json(out, 403, JSONObject().put("error", "The TV isn't signed in to a library right now."))
active = prefs().get(phone)?.substringBefore('|')
if (companion(r, out)) return
val h = standIn ?: handler ?: return json(out, 403, JSONObject().put("error", "The TV isn't signed in to a library right now."))
when ("${r.method} ${r.path}") {
"GET /api/channels" -> json(out, 200, JSONObject().put("next", CustomChannels.nextNumber() ?: JSONObject.NULL).put("channels", JSONArray(CustomChannels.list.map { c ->
c.toJson().put("on", h.isOn(c.number)).put("styleTitle", c.style.title).put("hoursLabel", c.hours?.label ?: "All day") })))
@@ -318,4 +322,51 @@ internal object PhoneLink {
else -> json(out, 404, JSONObject().put("error", "Not found"))
}
}
private val companionRoutes = setOf("GET /api/now", "GET /api/guide", "GET /api/catchup", "GET /api/rooms", "POST /api/key", "POST /api/volume",
"POST /api/channel", "POST /api/tune", "POST /api/section", "POST /api/catchup/watch")
/** The Companion's remote, guide, Catch Up and rooms (see [OrbitCompanion]); false when [r] is for Create your own. */
private fun companion(r: Request, out: OutputStream): Boolean {
if ("${r.method} ${r.path}" !in companionRoutes) return false
val s = screen ?: return true.also { json(out, 409, JSONObject().put("error", "Orbit is starting up on the TV.")) }
companion(r, out, s); return true
}
private fun companion(r: Request, out: OutputStream, s: CompanionScreen) {
val now = clock()
fun body() = runCatching { JSONObject(r.body) }.getOrNull() ?: JSONObject()
fun onScreen(block: () -> Unit) = runBlocking { withContext(mainDispatcher) { block() } }
when ("${r.method} ${r.path}") {
"GET /api/now" -> json(out, 200, OrbitCompanion.now(s, now))
"GET /api/guide" -> json(out, 200, OrbitCompanion.guide(s, now))
"GET /api/catchup" -> json(out, 200, JSONObject().put("shelves", OrbitCompanion.catchUp(s, now)))
"GET /api/rooms" -> json(out, 200, OrbitCompanion.rooms(appContext))
"POST /api/key" -> {
val code = OrbitCompanion.keys[body().optString("key")] ?: return json(out, 400, JSONObject().put("error", "Unknown button"))
OrbitCompanion.press(code); json(out, 202, JSONObject().put("ok", true))
}
"POST /api/volume" -> {
val direction = OrbitCompanion.volumes[body().optString("change")] ?: return json(out, 400, JSONObject().put("error", "Unknown change"))
runCatching { OrbitCompanion.volume(direction) }; json(out, 200, JSONObject().put("ok", true))
}
"POST /api/channel" -> {
val step = body().optInt("step").takeIf { it == 1 || it == -1 } ?: return json(out, 400, JSONObject())
val number = OrbitCompanion.stepped(s, step) ?: return json(out, 409, JSONObject().put("error", "No channels yet."))
onScreen { s.tune(number, 0) }; json(out, 200, JSONObject().put("number", number))
}
"POST /api/tune" -> {
val number = body().optInt("number").takeIf { n -> s.channels().any { it.number == n } } ?: return json(out, 404, JSONObject().put("error", "That channel isn't on this TV right now."))
onScreen { s.tune(number, 0) }; json(out, 200, JSONObject().put("number", number))
}
"POST /api/section" -> {
val section = OrbitCompanion.sections[body().optString("section")] ?: return json(out, 400, JSONObject().put("error", "Unknown place"))
onScreen { OrbitCompanion.open(section) }; json(out, 200, JSONObject().put("ok", true))
}
"POST /api/catchup/watch" -> {
val o = body()
val entry = OrbitCompanion.catchUpEntry(s, o.optInt("number"), o.optLong("startMs"), now) ?: return json(out, 410, JSONObject().put("error", "That's no longer in Catch Up."))
onScreen { s.tune(entry.channel.number, entry.offsetFrom(clock())) }; json(out, 200, JSONObject().put("ok", true))
}
else -> json(out, 404, JSONObject().put("error", "Not found"))
}
}
}
@@ -1,6 +1,6 @@
package tv.orbit.classic
/** The phone's pages: joining (a code, then the PIN if there is one) and the app itself, from assets/phone/app.html. */
/** The phone's pages: joining (a code, then the PIN if there is one), the Companion and Create your own, from assets/phone. */
internal object PhonePages {
private fun esc(s: String) = s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;")
private const val STYLE = """<meta name="viewport" content="width=device-width, initial-scale=1"><style>
@@ -9,12 +9,16 @@ main{width:min(92vw,380px)}h1{font-size:30px;margin:0 0 6px}p{color:#b7cbe8}inpu
padding:12px;border-radius:12px;border:2px solid #194c91;background:#12356b;color:#fff;margin:12px 0}button{width:100%;padding:14px;border:0;border-radius:12px;
background:#ffde59;color:#071632;font-size:18px;font-weight:700}.note{color:#ffde59}</style>"""
fun join(note: String?) = """<!doctype html><html><head><title>Orbit</title>$STYLE</head><body><main><h1>Orbit</h1>
<p>Enter the code shown on the TV in Settings › Channels › Create your own › On your phone.</p>${note?.let { "<p class='note'>${esc(it)}</p>" }.orEmpty()}
<p>Enter the code shown on the TV in Settings › Rooms and phones › Connect a phone.</p>${note?.let { "<p class='note'>${esc(it)}</p>" }.orEmpty()}
<form method="post" action="/join"><input name="code" inputmode="numeric" maxlength="4" autocomplete="one-time-code" autofocus><button>Join</button></form></main></body></html>"""
fun pin(k: String?, code: String?, wrong: Boolean) = """<!doctype html><html><head><title>Orbit</title>$STYLE</head><body><main><h1>Adult PIN</h1>
<p>The code worked. Making channels is for adults, so now enter your <b>Adult PIN</b>: the four digits that unlock After Dark on the TV.</p>
<p>The code worked. Joining a phone is for adults, so now enter your <b>Adult PIN</b>: the four digits that unlock After Dark on the TV.</p>
<p>It isn't the code on the TV screen.</p>${if (wrong) "<p class='note'>That PIN didn't match. It's the After Dark PIN, not the TV's code.</p>" else ""}
<form method="post" action="/join">${k?.let { "<input type='hidden' name='k' value='${esc(it)}'>" }.orEmpty()}${code?.let { "<input type='hidden' name='code' value='${esc(it)}'>" }.orEmpty()}
<input name="pin" type="password" inputmode="numeric" maxlength="8" autofocus><button>Continue</button></form></main></body></html>"""
fun app(): String = runCatching { PhoneLink.context()?.assets?.open("phone/app.html")?.use { it.readBytes().toString(Charsets.UTF_8) } }.getOrNull() ?: join("The page couldn't be loaded.")
private fun asset(name: String): String = runCatching { PhoneLink.context()?.assets?.open("phone/$name")?.use { it.readBytes().toString(Charsets.UTF_8) } }.getOrNull() ?: join("The page couldn't be loaded.")
/** Create your own, at /create. */
fun app(): String = asset("app.html")
/** The Companion, at /: Remote, Guide, Catch Up, Rooms and More (which leads to Create your own). */
fun companion(): String = asset("companion.html")
}
@@ -350,7 +350,20 @@ private fun RoomsDetail(firstSwitch: FocusRequester, modifier: Modifier) {
val context = LocalContext.current
var adding by remember { mutableStateOf(false) }
var naming by remember { mutableStateOf(false) }
var connecting by remember { mutableStateOf(false) }
if (adding) { AddRoomPanel(onClose = { adding = false }, modifier = modifier); return }
if (connecting) {
// Orbit Companion (Paul, 6 November 2026): the same one-time code as Create your own's, from where phones are listed.
BackHandler { connecting = false }
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
PhoneCard(Modifier.width(460.dp), "Connect a phone")
Text("Your phone becomes a remote for this TV: what's on, the guide, Catch Up and your rooms, in this TV's colours.", color = Muted, fontSize = 13.sp)
val done = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { done.requestFocus() } }
OrbitButton("Done", Modifier.focusRequester(done), onClick = { connecting = false })
}
return
}
Column(modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Heading("This room")
SettingLine(Rooms.name, "What other TVs see when they join this one.") {
@@ -368,8 +381,10 @@ private fun RoomsDetail(firstSwitch: FocusRequester, modifier: Modifier) {
SettingLine(room, "Copied this Orbit's set-up on ${day.dayOfMonth} ${day.month.getDisplayName(java.time.format.TextStyle.SHORT, java.util.Locale.UK)}. Not connected: it plans its own schedules.") {}
}
Heading("Phones")
if (PhoneLink.phones.isEmpty()) SettingLine("No phones yet", "Channels › Create your own › On your phone shows a code to scan.") {}
else PhoneLink.phones.forEachIndexed { i, (id, name) -> SettingLine(name, "Can make and edit your channels, and tune this TV.") {
SettingLine("Connect a phone", "Orbit Companion: your phone as a remote, with the guide, Catch Up and rooms. No app to install, just scan a code.") {
OrbitButton("Connect", onClick = { connecting = true })
}
PhoneLink.phones.forEachIndexed { i, (id, name) -> SettingLine(name, "A remote for this TV, and can make and edit your channels.") {
OrbitButton("Remove", if (i == 0) Modifier.focusRequester(firstSwitch) else Modifier, onClick = { PhoneLink.forget(id) }) } }
}
}
@@ -130,4 +130,55 @@ class PhoneLinkTest {
val everything = call("/api/channels", cookie = cookie).body + suggest
listOf("token", "api_key", "password", "X-Emby", "MediaBrowser", "http").forEach { assertFalse(it, everything.contains(it, ignoreCase = true)) }
}
// --- Orbit Companion (phase 1) ---
private val minute = 60_000L
private fun media(id: String, title: String) = ScheduledMedia(SampleItem(title, "Episode", 30, "", id, 30 * minute), "s-$id", 30 * minute)
private val now = 1_793_000_000_000L
private val companionChannels = listOf(SampleChannel(listOf(media("a1", "Friends / S1 E1"), media("a2", "Friends / S1 E2")), "Orbit One", 101),
SampleChannel(listOf(media("b1", "Hot Fuzz"), media("b2", "Paul")), "BritX", 308))
private val tuned = mutableListOf<Pair<Int, Long>>()
private var selected = 101
private fun joinedCookie(): String { val p = PhoneLink.beginPairing(); return call("/join", "POST", "code=${p.code}", form = true).cookie!! }
@Test fun theCompanionIsARemoteForTheTvItCameFrom() {
val pressed = mutableListOf<Int>(); val volume = mutableListOf<Int>()
OrbitCompanion.inject = { synchronized(pressed) { pressed += it } }; OrbitCompanion.volume = { volume += it }
PhoneLink.clock = { now }
PhoneLink.screen = CompanionScreen({ companionChannels }, { selected }, { true }, { 0 }, { n, o -> tuned += n to o; selected = n })
try {
assertTrue(call("/api/now").body.contains("Enter the code")) // not joined: nothing
val cookie = joinedCookie()
assertTrue(call("/", cookie = cookie).status == 200)
val n = JSONObject(call("/api/now", cookie = cookie).body)
assertEquals(101, n.getJSONObject("channel").getInt("number")); assertEquals("Friends", n.getJSONObject("now").getString("title"))
assertTrue(n.getJSONObject("look").getString("accent").startsWith("#")); assertTrue(n.getBoolean("watching"))
val g = JSONObject(call("/api/guide", cookie = cookie).body).getJSONArray("channels")
assertEquals(listOf(101, 308), (0 until g.length()).map { g.getJSONObject(it).getInt("number") })
// Buttons are pressed as the TV's own remote would, and only the known ones.
assertEquals(202, call("/api/key", "POST", """{"key":"ok"}""", cookie = cookie).status)
assertEquals(400, call("/api/key", "POST", """{"key":"power"}""", cookie = cookie).status)
assertEquals(403, call("/api/key", "POST", """{"key":"ok"}""", cookie = cookie, orbit = false).status)
repeat(50) { if (synchronized(pressed) { pressed.isEmpty() }) Thread.sleep(20) }
assertEquals(listOf(android.view.KeyEvent.KEYCODE_DPAD_CENTER), synchronized(pressed) { pressed.toList() })
assertEquals(200, call("/api/volume", "POST", """{"change":"up"}""", cookie = cookie).status); assertEquals(1, volume.size)
// Channels round the guide, and tuning only to a channel this TV shows.
assertEquals(308, JSONObject(call("/api/channel", "POST", """{"step":1}""", cookie = cookie).body).getInt("number"))
assertEquals(101, JSONObject(call("/api/channel", "POST", """{"step":1}""", cookie = cookie).body).getInt("number"))
assertEquals(404, call("/api/tune", "POST", """{"number":999}""", cookie = cookie).status)
assertEquals(200, call("/api/tune", "POST", """{"number":308}""", cookie = cookie).status)
assertEquals(listOf(308 to 0L, 101 to 0L, 308 to 0L), tuned)
assertEquals(200, call("/api/section", "POST", """{"section":"arcade"}""", cookie = cookie).status); assertEquals("Arcade", OrbitCompanion.asked?.first)
assertEquals(400, call("/api/section", "POST", """{"section":"settings"}""", cookie = cookie).status)
// Catch Up only from what's really in it: these channels have no committed plan, so nothing is offered.
assertEquals(0, JSONObject(call("/api/catchup", cookie = cookie).body).getJSONArray("shelves").let { a -> (0 until a.length()).sumOf { a.getJSONObject(it).getJSONArray("entries").length() } })
assertEquals(410, call("/api/catchup/watch", "POST", """{"number":101,"startMs":${now - 60 * minute}}""", cookie = cookie).status)
// Titles and times only.
val everything = call("/api/now", cookie = cookie).body + call("/api/guide", cookie = cookie).body + call("/api/rooms", cookie = cookie).body
listOf("token", "api_key", "password", "X-Emby", "MediaBrowser", "s-a1", "a1").forEach { assertFalse(it, everything.contains("\"$it\"", ignoreCase = true)) }
// With the screen not ready, the phone is told so rather than refused.
PhoneLink.screen = null
assertEquals(409, call("/api/now", cookie = cookie).status)
} finally { PhoneLink.screen = null; OrbitCompanion.inject = { } }
}
}