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 ""}
"""
- 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