Orbit Companion: a phone can keep its own look (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:14:52 +00:00
co-authored by Claude Opus 5.5
parent 7ec5dfcee9
commit 19f705cd19
4 changed files with 23 additions and 9 deletions
@@ -104,8 +104,11 @@ let tab = (() => { try { return localStorage.getItem('orbit.tab') || '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;
// The phone wears the TV's look unless it keeps its own (Paul, 6 November 2026), kept on this phone only.
const keptLook = () => { try { return localStorage.getItem('orbit.look'); } catch { return null; } };
function look(tv) {
const own = keptLook() && state.looks?.find(l => l.name === keptLook());
const l = own || tv; 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;
@@ -191,7 +194,10 @@ function rooms() {
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">Look</div>
<div class="chips" style="flex-wrap:wrap"><button class="${keptLook() ? '' : 'on'}" data-look="">Follow this TV${state.now?.look ? ' (' + esc(state.now.look.name) + ')' : ''}</button>
${(state.looks || []).map(l => `<button class="${keptLook() === l.name ? 'on' : ''}" data-look="${esc(l.name)}">${esc(l.name)}</button>`).join('')}</div>
<div class="small">Following the TV, each room's remote wears that room's look. Pick one to keep it on this phone instead, whatever the TV is set to.</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>`;
}
@@ -209,6 +215,7 @@ async function load(what) {
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');
if (what === 'more') { state.looks = (await api('/api/looks')).looks; look(state.now?.look); }
offline = false;
} catch (e) { if (!offline) toast(e.message === 'Failed to fetch' ? "Orbit isn't open on the TV" : e.message); offline = true; }
render();
@@ -219,18 +226,19 @@ const send = (path, body, done) => { buzz(); api(path, body).then(j => { done?.(
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.tab) { tab = d.tab; try { localStorage.setItem('orbit.tab', tab); } catch {} closeSheet(); render(); if (tab !== 'remote') 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.look !== undefined) { try { d.look ? localStorage.setItem('orbit.look', d.look) : localStorage.removeItem('orbit.look'); } catch {} look(state.now?.look); render(); return; }
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);
render(); (keptLook() || tab === 'more' ? load('more') : Promise.resolve()).then(() => 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);
@@ -55,13 +55,16 @@ internal object OrbitCompanion {
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))
/** The TV's look, so the phone wears the same colours (Paul: by default; a phone can keep a look of its own from [looks]). */
fun look(look: OrbitLook = OrbitTheme.look): JSONObject = look.palette.let { p ->
JSONObject().put("name", 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))
}
/** Every look, for a phone that keeps its own rather than following the TV. */
fun looks(): JSONArray = JSONArray(OrbitLook.entries.map { look(it) })
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)
}
@@ -323,7 +323,7 @@ internal object PhoneLink {
}
}
private val companionRoutes = setOf("GET /api/now", "GET /api/guide", "GET /api/catchup", "GET /api/rooms", "POST /api/key", "POST /api/volume",
private val companionRoutes = setOf("GET /api/now", "GET /api/guide", "GET /api/catchup", "GET /api/rooms", "GET /api/looks", "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 {
@@ -340,6 +340,7 @@ internal object PhoneLink {
"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))
"GET /api/looks" -> json(out, 200, JSONObject().put("looks", OrbitCompanion.looks()))
"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))
@@ -153,6 +153,8 @@ class PhoneLinkTest {
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"))
// Every look, for a phone that keeps its own.
assertEquals(OrbitLook.entries.size, JSONObject(call("/api/looks", cookie = cookie).body).getJSONArray("looks").length())
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.