From 7ec5dfcee916df7f8e8395a9f414cfaabf126736 Mon Sep 17 00:00:00 2001 From: Pauljd Date: Fri, 6 Nov 2026 21:11:38 +0000 Subject: [PATCH] Orbit Companion phase 1 (from release 0.108.75) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../app/src/main/assets/phone/companion.html | 239 ++++++++++++++++++ .../java/tv/orbit/classic/CreateChannel.kt | 4 +- .../tv/orbit/classic/EmbyConnectionScreen.kt | 23 +- .../java/tv/orbit/classic/MainActivity.kt | 2 + .../main/java/tv/orbit/classic/Onboarding.kt | 3 + .../java/tv/orbit/classic/OrbitCompanion.kt | 112 ++++++++ .../main/java/tv/orbit/classic/PhoneLink.kt | 57 ++++- .../main/java/tv/orbit/classic/PhonePages.kt | 12 +- .../main/java/tv/orbit/classic/Settings.kt | 19 +- .../java/tv/orbit/classic/PhoneLinkTest.kt | 51 ++++ 10 files changed, 506 insertions(+), 16 deletions(-) create mode 100644 android/app/src/main/assets/phone/companion.html create mode 100644 android/app/src/main/java/tv/orbit/classic/OrbitCompanion.kt diff --git a/android/app/src/main/assets/phone/companion.html b/android/app/src/main/assets/phone/companion.html new file mode 100644 index 0000000..5d4f6f5 --- /dev/null +++ b/android/app/src/main/assets/phone/companion.html @@ -0,0 +1,239 @@ + +Orbit + + +
+
+ +
+ + diff --git a/android/app/src/main/java/tv/orbit/classic/CreateChannel.kt b/android/app/src/main/java/tv/orbit/classic/CreateChannel.kt index 8681c4b..5c5e90e 100644 --- a/android/app/src/main/java/tv/orbit/classic/CreateChannel.kt +++ b/android/app/src/main/java/tv/orbit/classic/CreateChannel.kt @@ -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)) diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt index f63c44b..cdeba24 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -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 diff --git a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt index 5cd3a26..e38aedc 100644 --- a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt +++ b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt @@ -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() } diff --git a/android/app/src/main/java/tv/orbit/classic/Onboarding.kt b/android/app/src/main/java/tv/orbit/classic/Onboarding.kt index 7b6a24b..d015aa2 100644 --- a/android/app/src/main/java/tv/orbit/classic/Onboarding.kt +++ b/android/app/src/main/java/tv/orbit/classic/Onboarding.kt @@ -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) } } diff --git a/android/app/src/main/java/tv/orbit/classic/OrbitCompanion.kt b/android/app/src/main/java/tv/orbit/classic/OrbitCompanion.kt new file mode 100644 index 0000000..0bc916f --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/OrbitCompanion.kt @@ -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, + 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?>(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 } +} 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 adbfbac..19823fc 100644 --- a/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt +++ b/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt @@ -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(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, val headers: Map, 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")) + } + } } diff --git a/android/app/src/main/java/tv/orbit/classic/PhonePages.kt b/android/app/src/main/java/tv/orbit/classic/PhonePages.kt index 0c26e67..fef0550 100644 --- a/android/app/src/main/java/tv/orbit/classic/PhonePages.kt +++ b/android/app/src/main/java/tv/orbit/classic/PhonePages.kt @@ -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("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'") private const val STYLE = """""" fun join(note: String?) = """Orbit$STYLE

Orbit

-

Enter the code shown on the TV in Settings › Channels › Create your own › On your phone.

${note?.let { "

${esc(it)}

" }.orEmpty()} +

Enter the code shown on the TV in Settings › Rooms and phones › Connect a phone.

${note?.let { "

${esc(it)}

" }.orEmpty()}
""" fun pin(k: String?, code: String?, wrong: Boolean) = """Orbit$STYLE

Adult PIN

-

The code worked. Making channels is for adults, so now enter your Adult PIN: the four digits that unlock After Dark on the TV.

+

The code worked. Joining a phone is for adults, so now enter your Adult PIN: the four digits that unlock After Dark on the TV.

It isn't the code on the TV screen.

${if (wrong) "

That PIN didn't match. It's the After Dark PIN, not the TV's code.

" else ""}
${k?.let { "" }.orEmpty()}${code?.let { "" }.orEmpty()}
""" - 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") } diff --git a/android/app/src/main/java/tv/orbit/classic/Settings.kt b/android/app/src/main/java/tv/orbit/classic/Settings.kt index 576ab37..d4acda7 100644 --- a/android/app/src/main/java/tv/orbit/classic/Settings.kt +++ b/android/app/src/main/java/tv/orbit/classic/Settings.kt @@ -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) }) } } } } 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 8f7c1ed..45e980a 100644 --- a/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt @@ -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>() + 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(); val volume = mutableListOf() + 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 = { } } + } }