From 19f705cd1993dacc3ea1d29485be9e85e2ca0c5e Mon Sep 17 00:00:00 2001 From: Pauljd Date: Fri, 6 Nov 2026 21:14:52 +0000 Subject: [PATCH] Orbit Companion: a phone can keep its own look (from release 0.108.75) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../app/src/main/assets/phone/companion.html | 18 +++++++++++++----- .../java/tv/orbit/classic/OrbitCompanion.kt | 9 ++++++--- .../main/java/tv/orbit/classic/PhoneLink.kt | 3 ++- .../java/tv/orbit/classic/PhoneLinkTest.kt | 2 ++ 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/assets/phone/companion.html b/android/app/src/main/assets/phone/companion.html index 5d4f6f5..0cb4f65 100644 --- a/android/app/src/main/assets/phone/companion.html +++ b/android/app/src/main/assets/phone/companion.html @@ -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') + `
Your channels
Create a channel Name, shows, style, hours -
Look
This phone wears the TV's look${state.now?.look ? ': ' + esc(state.now.look.name) + '' : ''}. Change it on the TV in Settings › Look.
+
Look
+
+ ${(state.looks || []).map(l => ``).join('')}
+
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.
About
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.
`; } @@ -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); diff --git a/android/app/src/main/java/tv/orbit/classic/OrbitCompanion.kt b/android/app/src/main/java/tv/orbit/classic/OrbitCompanion.kt index 0bc916f..6d0f49d 100644 --- a/android/app/src/main/java/tv/orbit/classic/OrbitCompanion.kt +++ b/android/app/src/main/java/tv/orbit/classic/OrbitCompanion.kt @@ -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) } diff --git a/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt b/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt index 19823fc..c6c906a 100644 --- a/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt +++ b/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt @@ -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)) diff --git a/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt b/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt index 45e980a..685839e 100644 --- a/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt @@ -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.