Create your own from a phone: the TV's own page, by QR code, on home Wi-Fi only
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m38s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m34s

Ported from release 0.108.47; screenshots of the phone page and the TV's start screen in design/create-your-own/shots.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-06 09:28:02 +00:00
co-authored by Claude Opus 5.5
parent 2211d96896
commit 7ea43f84f8
11 changed files with 505 additions and 22 deletions
+2
View File
@@ -27,6 +27,8 @@ dependencies {
implementation("androidx.tv:tv-material:1.0.0")
implementation("androidx.media3:media3-exoplayer:1.8.0")
implementation("androidx.media3:media3-ui:1.8.0")
// QR codes for the phone page (Create your own): pure Java, no Google services.
implementation("com.google.zxing:core:3.5.3")
testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20250107")
}
@@ -0,0 +1,81 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Orbit</title>
<style>
:root{--navy:#071632;--panel:#12356b;--blue:#194c91;--gold:#ffde59;--muted:#b7cbe8;--mint:#7ee0a8}
*{box-sizing:border-box}body{margin:0;background:var(--navy);color:#fff;font:16px/1.4 system-ui,-apple-system,sans-serif}
main{max-width:480px;margin:0 auto;padding:20px 16px 90px}h1{font-size:30px;margin:0}h2{font-size:24px;margin:0 0 4px}.sub{color:var(--muted);font-size:14px;margin:2px 0 14px}
.label{color:var(--muted);font-size:12px;font-weight:700;letter-spacing:.06em;margin:16px 0 8px}
.card{background:var(--panel);border-radius:14px;padding:12px 14px;margin-bottom:10px}.row{display:flex;gap:8px;align-items:center}
.num{color:var(--gold);font-weight:700;margin-right:8px}.title{font-weight:700;font-size:18px}.meta{color:var(--muted);font-size:13px;margin:4px 0 10px}
button{border:0;border-radius:10px;padding:9px 14px;font:inherit;font-weight:700;background:var(--blue);color:#fff}button.gold{background:var(--gold);color:var(--navy)}
button.wide{width:100%;padding:15px;font-size:18px}button:disabled{opacity:.5}input{width:100%;padding:12px;border-radius:10px;border:2px solid var(--blue);background:var(--panel);color:#fff;font:inherit}
.pick{display:flex;gap:8px;align-items:center}.pick .grow{flex:1;min-width:0}.reason{color:var(--muted);font-size:13px}.choice{border:3px solid transparent}.choice.on{border-color:var(--mint)}
.chips{display:flex;flex-wrap:wrap;gap:8px}.chips button.on{background:var(--gold);color:var(--navy)}.bar{position:fixed;left:0;right:0;bottom:0;padding:12px 16px;background:linear-gradient(transparent,var(--navy) 30%)}
.bar div{max-width:480px;margin:0 auto}.note{color:var(--gold);margin:10px 0}.chosen li{margin:4px 0}.ok{color:var(--mint);font-size:13px}
</style></head><body><main id="app"></main><div class="bar"><div id="bar"></div></div>
<script>
const $ = s => document.querySelector(s); const app = $('#app'), bar = $('#bar');
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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)} : {}); const j = await r.json(); if (!r.ok) throw new Error(j.error || 'Something went wrong'); return j; };
const STYLES = [['Standard','Standard TV',"As Orbit's own channels: shows take turns, episodes in order, shorts in slots"],['Shuffled','Shuffled','Episodes in a mixed order, every one before any repeats'],
['Blocks','Blocks','A timetable: each show at its own times, every day'],['Marathon','Marathon','One show at a time, several episodes back to back']];
const HOURS = [[null,'All day'],[{from:18,to:0},'Evenings, 18:00 to midnight'],[{from:6,to:12},'Mornings, 06:00 to noon'],[{from:9,to:17},'Daytime, 09:00 to 17:00']];
let draft = null, note = '';
async function home() {
draft = null; let data;
try { data = await api('/api/channels'); } catch (e) { app.innerHTML = `<h1>Orbit</h1><p class="note">${esc(e.message)}</p>`; bar.innerHTML = ''; return; }
app.innerHTML = `<h1>Orbit</h1><div class="sub ok">Connected to your TV</div>${note ? `<p class="note">${esc(note)}</p>` : ''}<div class="label">YOUR CHANNELS</div>` +
(data.channels.length ? data.channels.map(c => `<div class="card"><div><span class="num">${c.number}</span><span class="title">${esc(c.name)}</span></div>
<div class="meta">${esc(c.styleTitle)} · ${esc(c.hoursLabel)}${c.on ? '' : ' · off'}</div><div class="row"><button data-watch="${c.number}">Watch on TV</button><button data-edit="${c.number}">Edit</button></div></div>`).join('')
: `<p class="sub">No channels of your own yet.</p>`);
note = '';
bar.innerHTML = data.next ? `<button class="gold wide" id="new">+ Create a channel</button>` : '';
app.querySelectorAll('[data-watch]').forEach(b => b.onclick = async () => { b.disabled = true; try { await api('/api/watch', {number:+b.dataset.watch}); b.textContent = 'On the TV'; } catch (e) { b.textContent = e.message; } });
app.querySelectorAll('[data-edit]').forEach(b => b.onclick = async () => { const c = data.channels.find(x => x.number == b.dataset.edit);
const shows = await api('/api/shows?number=' + c.number); draft = {number:c.number, name:c.name, description:c.description||'', style:c.style, hours:c.hours, shows, skipped:[], offered:[]}; name(); });
if ($('#new')) $('#new').onclick = () => { draft = {name:'', description:'', style:'Standard', hours:null, shows:[], skipped:[], offered:[]}; name(); };
}
function name() {
app.innerHTML = `<h2>${draft.number ? 'Edit ' + esc(draft.name) : 'New channel'}</h2><div class="sub">Step 1 of 4 · Name</div><input id="name" maxlength="40" placeholder="Name, like 90s Sci-Fi" value="${esc(draft.name)}">`;
bar.innerHTML = `<div class="row"><button id="back">Back</button><button class="gold wide" id="next">Next: shows and films</button></div>`;
$('#back').onclick = home; $('#next').onclick = () => { draft.name = $('#name').value.trim(); if (draft.name) shows(); };
}
function shows() {
const offered = draft.offered.filter(s => !draft.shows.some(c => c.id === s.id) && !draft.skipped.includes(s.id));
app.innerHTML = `<h2>Shows and films</h2><div class="sub">Step 2 of 4 · describe it, or search for a title</div>
<div class="row"><input id="d" placeholder="90s sci-fi, late evenings" value="${esc(draft.description)}"><button class="gold" id="go">Suggest</button></div>
${note ? `<p class="note">${esc(note)}</p>` : ''}<div class="label">THE CHAIR SUGGESTS</div>` +
offered.map(s => `<div class="card pick"><div class="grow"><div class="title">${esc(s.title)}</div><div class="reason">${s.type === 'Movie' ? 'Film' : 'Series'}${s.year ? ' · ' + s.year : ''} · ${esc(s.reason)}</div></div>
<button class="gold" data-add="${esc(s.id)}">Add</button><button data-skip="${esc(s.id)}">Skip</button></div>`).join('') +
`<div class="label">IN YOUR CHANNEL (${draft.shows.length})</div><ul class="chosen">${draft.shows.map(s => `<li>${esc(s.title)} <button data-out="${esc(s.id)}">Remove</button></li>`).join('')}</ul>`;
note = '';
bar.innerHTML = `<div class="row"><button id="back">Back</button><button class="gold wide" id="next" ${draft.shows.length ? '' : 'disabled'}>Next: how it plays</button></div>`;
$('#go').onclick = async () => { draft.description = $('#d').value.trim(); if (!draft.description) return; $('#go').disabled = true;
try { draft.offered = await api('/api/suggest?d=' + encodeURIComponent(draft.description) + '&chosen=' + draft.shows.map(s => s.id).join(',') + '&skipped=' + draft.skipped.join(','));
if (!draft.offered.length) note = 'Nothing in your library fits that yet. Try other words, or a title.'; } catch (e) { note = e.message; } shows(); };
app.querySelectorAll('[data-add]').forEach(b => b.onclick = () => { draft.shows.push(draft.offered.find(s => s.id === b.dataset.add)); shows(); });
app.querySelectorAll('[data-skip]').forEach(b => b.onclick = () => { draft.skipped.push(b.dataset.skip); shows(); });
app.querySelectorAll('[data-out]').forEach(b => b.onclick = () => { draft.shows = draft.shows.filter(s => s.id !== b.dataset.out); shows(); });
$('#back').onclick = name; $('#next').onclick = style;
}
function style() {
app.innerHTML = `<h2>How it plays</h2><div class="sub">Step 3 of 4 · it shapes what's planned from then on, never what's booked</div>` +
STYLES.map(([k, t, l]) => `<div class="card choice ${draft.style === k ? 'on' : ''}" data-style="${k}"><div class="title">${t}</div><div class="reason">${l}</div></div>`).join('');
bar.innerHTML = `<div class="row"><button id="back">Back</button><button class="gold wide" id="next">Next: when it's on</button></div>`;
app.querySelectorAll('[data-style]').forEach(c => c.onclick = () => { draft.style = c.dataset.style; style(); });
$('#back').onclick = shows; $('#next').onclick = hours;
}
function hours() {
const same = h => JSON.stringify(h) === JSON.stringify(draft.hours);
app.innerHTML = `<h2>When it's on</h2><div class="sub">Step 4 of 4 · outside its hours the channel shows a "back at" card</div><div class="chips">` +
HOURS.map(([h, l], i) => `<button class="${same(h) ? 'on' : ''}" data-h="${i}">${l}</button>`).join('') + `</div>
<div class="card" style="margin-top:18px"><div class="title">${esc(draft.name)}</div><div class="meta">Yours · ${draft.shows.length} titles · ${STYLES.find(s => s[0] === draft.style)[1]} · ${HOURS.find(([h]) => same(h))?.[1] || 'Set hours'}</div></div>`;
bar.innerHTML = `<div class="row"><button id="back">Back</button><button class="gold wide" id="make">${draft.number ? 'Save changes' : 'Create and switch on'}</button></div>`;
app.querySelectorAll('[data-h]').forEach(b => b.onclick = () => { draft.hours = HOURS[+b.dataset.h][0]; hours(); });
$('#back').onclick = style;
$('#make').onclick = async () => { $('#make').disabled = true;
try { const r = await api('/api/channels', {number:draft.number, name:draft.name, description:draft.description, style:draft.style, hours:draft.hours, shows:draft.shows}); note = r.message; }
catch (e) { note = e.message; } home(); };
}
home();
</script></body></html>
@@ -22,6 +22,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
@@ -45,7 +46,7 @@ internal class ChannelMaker(
/** A channel's shows as they stand, for editing. */
val showsOf: suspend (Int) -> List<Suggestion> = { emptyList() })
internal enum class CreateStep(val title: String) { Start("Create a channel"), Name("Name and number"), Shows("Shows"), Style("How it plays"), Hours("When it's on") }
internal enum class CreateStep(val title: String) { Start("Create a channel"), Name("Name and number"), Shows("Shows and films"), Style("How it plays"), Hours("When it's on") }
private val HourChoices = listOf(null to "All day", OnHours(18, 0) to "Evenings, 18:00 to midnight", OnHours(6, 12) to "Mornings, 06:00 to noon", OnHours(9, 17) to "Daytime, 09:00 to 17:00")
@@ -80,12 +81,12 @@ internal fun CreateChannelFlow(maker: ChannelMaker, editing: CustomChannel?, onD
CreateStep.Start -> Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
ChoiceCard("On this TV", listOf("Name it and pick its number", "Add shows, or let the Director's Chair suggest them",
"Choose how it plays", "Choose when it's on"), Modifier.weight(1f).focusRequester(first)) { step = CreateStep.Name }
ChoiceCard("On your phone", listOf("Scan a code with your phone to make channels there", "Comes in the next update"), Modifier.weight(1f), enabled = false) { }
PhoneCard(Modifier.weight(1f))
}
CreateStep.Name -> {
Text("Your channel goes in Yours, as $number.", color = Muted, fontSize = 13.sp)
OrbitField(name, { if (it.length <= 40) name = it }, "Name, like 90s Sci-Fi", Modifier.fillMaxWidth().focusRequester(first))
OrbitButton("Next: shows", Modifier.width(220.dp), enabled = name.isNotBlank()) { step = CreateStep.Shows }
OrbitButton("Next: shows and films", Modifier.width(220.dp), enabled = name.isNotBlank()) { step = CreateStep.Shows }
}
CreateStep.Shows -> Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Column(Modifier.weight(1.7f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -178,6 +179,37 @@ private fun OrbitField(value: String, onChange: (String) -> Unit, hint: String,
}
}
/** On your phone: a QR code and a code to type, both one-time, while this screen shows. */
@Composable
private fun PhoneCard(modifier: Modifier) {
val pairing = remember { PhoneLink.beginPairing() }
val url = remember { PhoneLink.pairingUrl(pairing) }
val qr = remember(url) { url?.let { qrBitmap(it, 360) } }
Column(modifier.background(Panel, RoundedCornerShape(12.dp)).padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("On your phone", 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.CenterVertically) {
androidx.compose.foundation.Image(qr, "QR code for the phone page", Modifier.size(130.dp).background(Color.White, RoundedCornerShape(6.dp)).padding(6.dp))
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Scan with your phone's camera", color = Muted, fontSize = 12.sp)
Text("or go to", color = Muted, fontSize = 12.sp)
Text(url.substringBefore("/join"), color = Ink, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
Text("and enter", color = Muted, fontSize = 12.sp)
Text(pairing.code, Modifier.background(Navy, RoundedCornerShape(6.dp)).padding(horizontal = 10.dp, vertical = 2.dp), color = Yellow, fontFamily = Anton, fontSize = 24.sp)
}
}
Text(PhoneLink.active?.let { "Connected: $it" } ?: "Same Wi-Fi as this TV. The code works once, for two minutes.", color = if (PhoneLink.active != null) Mint else Muted, fontSize = 11.sp)
}
}
/** A QR code as a picture, black on white, by ZXing. */
internal fun qrBitmap(text: String, size: Int): androidx.compose.ui.graphics.ImageBitmap {
val matrix = com.google.zxing.qrcode.QRCodeWriter().encode(text, com.google.zxing.BarcodeFormat.QR_CODE, size, size, mapOf(com.google.zxing.EncodeHintType.MARGIN to 1))
val pixels = IntArray(size * size) { i -> if (matrix[i % size, i / size]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt() }
return android.graphics.Bitmap.createBitmap(pixels, size, size, android.graphics.Bitmap.Config.ARGB_8888).asImageBitmapCompat()
}
private fun android.graphics.Bitmap.asImageBitmapCompat(): androidx.compose.ui.graphics.ImageBitmap = asImageBitmap()
/** A channel of your own outside its hours: its name, and when it's back. */
@Composable
internal fun OffAirCard(channel: SampleChannel, back: String) {
@@ -480,8 +480,6 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
}
return
}
if (!visible) return
if (onboarding && session != null) return
fun setSmartSchedule(programming: ChannelProgramming, turnOn: Boolean) {
val active = session ?: return
val existing = smartLoop(programming)
@@ -566,6 +564,36 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
finally { busy = false; savingSuggestions = false }
}
}
val channelMaker = session?.let { active -> ChannelMaker(
suggest = { description, chosen, skipped -> withContext(Dispatchers.IO) {
val wish = wishOf(description)
val rows = catalogue.chairRows(wish.genres, wish.years, wish.type, if (wish.genres.isEmpty() && wish.years == null) description else null)
chairSuggestions(description, rows, catalogue.chairRowsById(chosen.map { it.id }), skipped) } },
search = { text -> withContext(Dispatchers.IO) { catalogue.chairRows(titleLike = text, limit = 30).map { Suggestion(it.id, it.title, it.year, it.type, "", "In your library") } } },
showsOf = { number -> withContext(Dispatchers.IO) { scheduleStore.loadChoices(active.accountId, number).accepted } },
create = { channel, shows ->
withContext(Dispatchers.IO) {
CustomChannels.upsert(workspace.scheduleDir, channel)
val before = scheduleStore.loadChoices(active.accountId, channel.number)
scheduleStore.saveChoices(active.accountId, channel.number, ChannelChoices(shows, before.rejected))
}
android.util.Log.i("OrbitSchedule", "yours channel=${channel.number} style=${channel.style} titles=${shows.size}")
SmartChannels.firstOrNull { it.number == channel.number }?.let { setSmartSchedule(it, true) }
"${channel.name} is ready on ${channel.number}. Its first programmes are being planned now."
}) }
// The phone page (Create your own from a phone) asks the same of this screen as Settings does, and can tune the TV.
DisposableEffect(channelMaker) {
PhoneLink.handler = channelMaker?.let { m -> PhoneHandler(m, isOn = { n -> scheduledChannels.containsKey(n) }, watch = { n ->
if (!lockedOut(n) && !profileHides(n) && SmartChannels.any { it.number == n }) {
if (selectedNumber != n) { lastNumber = selectedNumber; selectedNumber = n }
channelClockOffset = 0; watchingChannel = true
if (!mainGuide && !mainHome && !mainWhatsOn && !mainCatchUp) onGuide()
}
}) }
onDispose { PhoneLink.handler = null }
}
if (!visible) return
if (onboarding && session != null) return
suggesting?.let { programming ->
SuggestionsScreen(programming, suggestions, tickedSuggestions, busy, message, modifier,
onToggle = { id -> tickedSuggestions = if (id in tickedSuggestions) tickedSuggestions - id else tickedSuggestions + id },
@@ -620,23 +648,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
else -> "${idea.theme} dismissed: it won't be proposed again unless its films change." }
} }) }, isOn = { scheduledChannels.containsKey(it) }, onSwitch = { programming, on -> setSmartSchedule(programming, on) }, onSuggestions = { openSuggestions(it) },
onSignOut = { job?.cancel(); closeSession(signOut = true) }, onRemoveServer = { job?.cancel(); closeSession(remove = true) },
maker = session?.let { active -> ChannelMaker(
suggest = { description, chosen, skipped -> withContext(Dispatchers.IO) {
val wish = wishOf(description)
val rows = catalogue.chairRows(wish.genres, wish.years, wish.type, if (wish.genres.isEmpty() && wish.years == null) description else null)
chairSuggestions(description, rows, catalogue.chairRowsById(chosen.map { it.id }), skipped) } },
search = { text -> withContext(Dispatchers.IO) { catalogue.chairRows(titleLike = text, limit = 30).map { Suggestion(it.id, it.title, it.year, it.type, "", "In your library") } } },
showsOf = { number -> withContext(Dispatchers.IO) { scheduleStore.loadChoices(active.accountId, number).accepted } },
create = { channel, shows ->
withContext(Dispatchers.IO) {
CustomChannels.upsert(workspace.scheduleDir, channel)
val before = scheduleStore.loadChoices(active.accountId, channel.number)
scheduleStore.saveChoices(active.accountId, channel.number, ChannelChoices(shows, before.rejected))
}
android.util.Log.i("OrbitSchedule", "yours channel=${channel.number} style=${channel.style} titles=${shows.size}")
SmartChannels.firstOrNull { it.number == channel.number }?.let { setSmartSchedule(it, true) }
"${channel.name} is ready on ${channel.number}. Its first programmes are being planned now."
}) },
maker = channelMaker,
windingDown = windingDown, onWindDown = { on ->
val tots = scheduledChannels[TOTS_NUMBER]
val active = session
@@ -44,6 +44,8 @@ internal val Barlow = FontFamily(Font(R.font.barlow_regular), Font(R.font.barlow
internal fun clock(time: Long) = SimpleDateFormat("HH:mm", Locale.UK).format(Date(time))
class MainActivity : ComponentActivity() {
override fun onStart() { super.onStart(); if (PhoneLink.phones.isNotEmpty()) PhoneLink.start() }
override fun onStop() { PhoneLink.stop(); super.onStop() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@@ -56,6 +58,8 @@ class MainActivity : ComponentActivity() {
}
// Before anything is drawn, so a locked After Dark never shows for a moment.
AfterDarkLock.load(this)
// The phone page's server runs only while Orbit is open (Create your own from a phone).
PhoneLink.init(this)
Profiles.load(this)
// The chosen look, before the first frame, so a light look never flashes navy.
OrbitTheme.load(this)
@@ -0,0 +1,236 @@
package tv.orbit.classic
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.io.OutputStream
import java.net.Inet4Address
import java.net.InetAddress
import java.net.NetworkInterface
import java.net.ServerSocket
import java.net.Socket
import java.net.URLDecoder
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.concurrent.Executors
/**
* Create your own from a phone (Paul, 6 November 2026, from the approved design/create-your-own): the TV serves a small page
* on the home Wi-Fi, which a phone opens by scanning a QR code on the TV. No app, no account, and the Emby sign-in never
* leaves the TV. The rules:
* - Only addresses on the home network are answered; anything else is closed unread.
* - A phone joins with a one-time code from the TV, good for two minutes and five tries, and the Grown-ups PIN if one is set.
* - A joined phone holds a long random token in a cookie (HttpOnly, SameSite=Strict); the TV keeps only its hash, and
* Settings lists phones and removes them. Changes also need a header a cross-site page can't send.
* - The server runs only while Orbit is open, and only once a phone has joined or one is joining.
*/
internal class Pairing(val code: String, val secret: String, val untilMs: Long) { var tries = 0 }
/** What the phone may ask of the screen that holds the library: Create your own's [ChannelMaker], what's on, and tuning. */
internal class PhoneHandler(val maker: ChannelMaker, val isOn: (Int) -> Boolean, val watch: (Int) -> Unit)
/** Where joined phones are kept: their token's hash, with a name and when they joined. A map stands in for it in tests. */
internal interface PhoneStore { fun all(): Map<String, String>; fun get(id: String): String?; fun put(id: String, value: String); fun remove(id: String) }
private class PrefsPhoneStore(context: Context) : PhoneStore {
private val prefs = context.getSharedPreferences("orbit_phones", Context.MODE_PRIVATE)
override fun all() = prefs.all.mapNotNull { (k, v) -> (v as? String)?.let { k to it } }.toMap()
override fun get(id: String) = prefs.getString(id, null)
override fun put(id: String, value: String) { prefs.edit().putString(id, value).apply() }
override fun remove(id: String) { prefs.edit().remove(id).apply() }
}
internal object PhoneLink {
const val PORT = 8099
private const val COOKIE = "orbit_phone"
private val random = SecureRandom()
@Volatile private var server: ServerSocket? = null
private val workers = Executors.newFixedThreadPool(4)
@Volatile var handler: PhoneHandler? = null
var pairing by mutableStateOf<Pairing?>(null)
private set
/** The phone that last did something, for the TV to say so. */
var active by mutableStateOf<String?>(null)
var phones by mutableStateOf<List<Pair<String, String>>>(emptyList())
private set
private var appContext: Context? = null
private var store: PhoneStore? = null
/** Answers a PIN for joining; the After Dark lock's, unless a test says otherwise. */
internal var pinCheck: (String) -> Boolean = { pin -> appContext?.let { AfterDarkLock.check(it, pin) } == true }
internal var pinNeeded: () -> Boolean = { AfterDarkLock.pinSet }
/** Who may talk to the server: addresses on the home network only. */
internal var allowed: (InetAddress) -> Boolean = EmbyServer::isHomeAddress
/** Where the server listens: every interface on the TV (tests keep it to this computer). */
internal var bindTo: InetAddress? = null
internal var port = PORT
/** Changes the phone asks for run on the screen's own thread. */
internal var mainDispatcher: kotlinx.coroutines.CoroutineDispatcher = Dispatchers.Main
internal fun context() = appContext
private fun prefs() = store!!
private fun hash(token: String) = MessageDigest.getInstance("SHA-256").digest(token.toByteArray()).joinToString("") { "%02x".format(it) }
private fun loadPhones() { phones = prefs().all().map { (k, v) -> k to v.substringBefore('|') }.sortedBy { it.second } }
/** The TV's address on the home network, for the QR code. */
fun homeAddress(): String? = runCatching {
// The Wi-Fi or Ethernet address: 192.168.x first, then 10.x, then 172.16-31.x, never a virtual interface's.
NetworkInterface.getNetworkInterfaces().toList().filter { it.isUp && !it.isLoopback && !it.isVirtual && !Regex("^(docker|br-|veth|virbr|tun|rmnet)").containsMatchIn(it.name) }
.flatMap { it.inetAddresses.toList() }.filter { it is Inet4Address && EmbyServer.isHomeAddress(it) }
.minByOrNull { a -> when ((a.address[0].toInt() and 255)) { 192 -> 0; 10 -> 1; else -> 2 } }?.hostAddress
}.getOrNull()
fun init(context: Context) { appContext = context.applicationContext; store = PrefsPhoneStore(context.applicationContext); loadPhones(); if (phones.isNotEmpty()) start() }
/** For tests: a store of its own, no Android. */
internal fun initForTest(testStore: PhoneStore) { store = testStore; loadPhones() }
/** A new one-time code, and the server running to receive it. */
fun beginPairing(): Pairing {
val code = "%04d".format(random.nextInt(10_000))
val secret = ByteArray(16).also(random::nextBytes).joinToString("") { "%02x".format(it) }
return Pairing(code, secret, System.currentTimeMillis() + 2 * 60_000L).also { pairing = it; start() }
}
fun pairingUrl(p: Pairing): String? = homeAddress()?.let { "http://$it:$PORT/join?k=${p.secret}" }
fun forget(id: String) { prefs().remove(id); loadPhones(); if (phones.isEmpty() && pairing == null) stop() }
@Synchronized fun start() {
if (server != null || store == null) return
val socket = runCatching { ServerSocket().apply { reuseAddress = true; bind(java.net.InetSocketAddress(bindTo, port), 8) } }.getOrNull() ?: return
server = socket
Thread({
while (!socket.isClosed) {
val client = runCatching { socket.accept() }.getOrNull() ?: break
workers.execute { runCatching { client.use { serve(it) } } }
}
}, "orbit-phone-link").apply { isDaemon = true }.start()
}
@Synchronized fun stop() { runCatching { server?.close() }; server = null }
// --- 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)
private fun readRequest(input: InputStream): Request? {
val head = ByteArrayOutputStream()
var last4 = 0
while (head.size() < 8_192) {
val b = input.read(); if (b < 0) return null
head.write(b); last4 = (last4 shl 8) or b
if (last4 == 0x0d0a0d0a) break
}
val lines = head.toString("ISO-8859-1").split("\r\n")
val parts = lines.first().split(' ')
if (parts.size < 2) return null
val headers = lines.drop(1).filter { ':' in it }.associate { it.substringBefore(':').trim().lowercase() to it.substringAfter(':').trim() }
val length = headers["content-length"]?.toIntOrNull()?.coerceIn(0, 65_536) ?: 0
val body = ByteArray(length).also { var read = 0; while (read < length) { val n = input.read(it, read, length - read); if (n < 0) break; read += n } }.toString(Charsets.UTF_8)
val path = parts[1].substringBefore('?')
val query = parts[1].substringAfter('?', "").split('&').filter { '=' in it }.associate { URLDecoder.decode(it.substringBefore('='), "UTF-8") to URLDecoder.decode(it.substringAfter('='), "UTF-8") }
return Request(parts[0], path, query, headers, body)
}
private fun respond(out: OutputStream, status: Int, type: String, body: ByteArray, extra: List<String> = emptyList()) {
val reason = mapOf(200 to "OK", 302 to "Found", 400 to "Bad Request", 403 to "Forbidden", 404 to "Not Found", 429 to "Too Many Requests")[status] ?: "OK"
val head = (listOf("HTTP/1.1 $status $reason", "Content-Type: $type", "Content-Length: ${body.size}", "Cache-Control: no-store",
"X-Content-Type-Options: nosniff", "X-Frame-Options: DENY", "Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'",
"Connection: close") + extra).joinToString("\r\n") + "\r\n\r\n"
out.write(head.toByteArray(Charsets.ISO_8859_1)); out.write(body); out.flush()
}
private fun json(out: OutputStream, status: Int, value: Any) = respond(out, status, "application/json; charset=utf-8", value.toString().toByteArray())
private fun page(out: OutputStream, html: String, extra: List<String> = emptyList()) = respond(out, 200, "text/html; charset=utf-8", html.toByteArray(), extra)
private fun phoneOf(r: Request): String? {
val token = r.headers["cookie"]?.split(';')?.map { it.trim() }?.firstOrNull { it.startsWith("$COOKIE=") }?.substringAfter('=') ?: return null
if (token.length != 64) return null
val id = hash(token)
return prefs().get(id)?.let { id }
}
private fun serve(socket: Socket) {
socket.soTimeout = 15_000
if (!allowed(socket.inetAddress)) return
val input = BufferedInputStream(socket.getInputStream()); val out = socket.getOutputStream()
val r = readRequest(input) ?: return
val phone = phoneOf(r)
when {
r.path == "/join" -> join(r, out)
phone == null -> page(out, PhonePages.join(null))
r.path == "/" -> page(out, PhonePages.app())
r.method == "POST" && r.headers["x-orbit"] != "1" -> json(out, 403, JSONObject().put("error", "Refused"))
else -> api(r, out, phone)
}
}
private fun join(r: Request, out: OutputStream) {
val p = pairing
// The code and PIN come in a posted form, so they never sit in an address or history.
val form = if (r.method == "POST") r.body.split('&').filter { '=' in it }.associate { URLDecoder.decode(it.substringBefore('='), "UTF-8") to URLDecoder.decode(it.substringAfter('='), "UTF-8") } else emptyMap()
val given = form["k"] ?: r.query["k"]; val code = form["code"]?.filter(Char::isDigit); val pin = form["pin"]?.filter(Char::isDigit)
if (p == null || System.currentTimeMillis() > p.untilMs) return page(out, PhonePages.join("That code has run out. Ask the TV for a new one."))
if (given == null && code == null) return page(out, PhonePages.join(null))
if (p.tries++ >= 5) { pairing = null; return page(out, PhonePages.join("Too many tries. Ask the TV for a new code.")) }
val ok = (given != null && MessageDigest.isEqual(given.toByteArray(), p.secret.toByteArray())) || (code != null && code == p.code)
if (!ok) return page(out, PhonePages.join("That code didn't match. Check the TV."))
if (pinNeeded() && (pin == null || !pinCheck(pin))) return page(out, PhonePages.pin(given, code, pin != null))
// Joined: a long random token the phone keeps; the TV keeps only its hash.
val token = ByteArray(32).also(random::nextBytes).joinToString("") { "%02x".format(it) }
val name = phoneName(r.headers["user-agent"].orEmpty())
prefs().put(hash(token), "$name|${System.currentTimeMillis()}")
pairing = null; loadPhones(); active = name
respond(out, 302, "text/plain", ByteArray(0), listOf("Location: /", "Set-Cookie: $COOKIE=$token; HttpOnly; SameSite=Strict; Path=/; Max-Age=31536000"))
}
private fun phoneName(agent: String) = when {
"iPhone" in agent -> "iPhone"; "iPad" in agent -> "iPad"
"Android" in agent -> Regex("Android [^;]*; ([^;)]+)").find(agent)?.groupValues?.get(1)?.trim()?.take(30) ?: "Android phone"
else -> "Phone"
}
private fun suggestionJson(s: Suggestion) = JSONObject().put("id", s.id).put("title", s.title).put("year", s.year ?: JSONObject.NULL).put("type", s.type).put("reason", s.reason)
private fun suggestionOf(o: JSONObject) = Suggestion(o.getString("id").also { require(EmbyClient.validId(it)) }, o.getString("title").take(160),
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 = handler ?: return json(out, 403, JSONObject().put("error", "The TV isn't signed in to a library right now."))
active = prefs().get(phone)?.substringBefore('|')
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") })))
"GET /api/shows" -> {
val number = r.query["number"]?.toIntOrNull() ?: return json(out, 400, JSONObject())
json(out, 200, JSONArray(runBlocking { h.maker.showsOf(number) }.map(::suggestionJson)))
}
"GET /api/suggest" -> {
val chosen = r.query["chosen"].orEmpty().split(',').filter(EmbyClient::validId).map { Suggestion(it, "", null, "Series", "", "") }
val skipped = r.query["skipped"].orEmpty().split(',').filter(EmbyClient::validId).toSet()
val d = r.query["d"].orEmpty().take(120)
json(out, 200, JSONArray(runBlocking { h.maker.suggest(d, chosen, skipped).ifEmpty { h.maker.search(d) } }.map(::suggestionJson)))
}
"POST /api/channels" -> {
val o = runCatching { JSONObject(r.body) }.getOrNull() ?: return json(out, 400, JSONObject().put("error", "Unreadable"))
val number = o.optInt("number").takeIf { it in YOURS_FIRST..YOURS_LAST } ?: CustomChannels.nextNumber() ?: return json(out, 400, JSONObject().put("error", "Every number is taken."))
val shows = runCatching { o.getJSONArray("shows").let { a -> (0 until minOf(a.length(), 200)).map { suggestionOf(a.getJSONObject(it)) } } }.getOrNull()
?: return json(out, 400, JSONObject().put("error", "Unreadable shows"))
val channel = runCatching { CustomChannel(number, o.getString("name").trim().take(40), runCatching { PlayStyle.valueOf(o.optString("style")) }.getOrDefault(PlayStyle.Standard),
o.optJSONObject("hours")?.let { OnHours(it.getInt("from").coerceIn(0, 23), it.getInt("to").coerceIn(0, 24)) }, o.optString("description").take(120), shows.map { it.title }) }.getOrNull()
?: return json(out, 400, JSONObject().put("error", "A channel needs a name."))
if (shows.isEmpty()) return json(out, 400, JSONObject().put("error", "Add at least one show or film."))
val said = runBlocking { withContext(mainDispatcher) { h.maker.create(channel, shows) } }
json(out, 200, JSONObject().put("message", said).put("number", number))
}
"POST /api/watch" -> {
val number = runCatching { JSONObject(r.body).getInt("number") }.getOrNull() ?: return json(out, 400, JSONObject())
runBlocking { withContext(mainDispatcher) { h.watch(number) } }
json(out, 200, JSONObject().put("ok", true))
}
else -> json(out, 404, JSONObject().put("error", "Not found"))
}
}
}
@@ -0,0 +1,19 @@
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. */
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>
body{margin:0;background:#071632;color:#fff;font:16px/1.4 system-ui,sans-serif;display:flex;min-height:100vh;align-items:center;justify-content:center}
main{width:min(92vw,380px)}h1{font-size:30px;margin:0 0 6px}p{color:#b7cbe8}input{width:100%;box-sizing:border-box;font-size:28px;letter-spacing:8px;text-align:center;
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 a channel › On your 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>Grown-ups PIN</h1>
<p>Making channels is for Grown-ups. Enter the PIN you use on the TV.</p>${if (wrong) "<p class='note'>That PIN didn't match.</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.")
}
@@ -296,6 +296,9 @@ private fun AccountDetail(server: String, titles: Long?, onSignOut: () -> Unit,
}
}
} else SettingLine("Remove this server", "For leaving a box or starting again: deletes this server's data from this box only.") { OrbitButton("Remove", onClick = { removing = true }) }
// Phones that have joined for Create your own: each can be removed here.
if (PhoneLink.phones.isEmpty()) SettingLine("Phones", "No phones have joined. Create a channel › On your phone shows a code.") {}
else PhoneLink.phones.forEach { (id, name) -> SettingLine("Phone: $name", "Can make and edit your channels, and tune this TV.") { OrbitButton("Remove", onClick = { PhoneLink.forget(id) }) } }
SettingLine("Welcome tour", "See the first-launch steps a new box goes through, from Ollie's welcome to You're on air. Nothing is changed by it.") {
OrbitButton("Show it", onClick = { Onboarding.start(tour = true) })
}
@@ -0,0 +1,94 @@
package tv.orbit.classic
import kotlinx.coroutines.Dispatchers
import org.json.JSONArray
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import java.net.HttpURLConnection
import java.net.InetAddress
import java.net.URL
/**
* The phone page's server (Create your own from a phone, Paul, 6 November 2026), run on this computer only: loopback stands
* in for the home network here, and nothing listens beyond this computer.
*/
class PhoneLinkTest {
private val phones = mutableMapOf<String, String>()
private val watched = mutableListOf<Int>()
private var made: Pair<CustomChannel, List<Suggestion>>? = null
private val port = 18_099
private val base = "http://127.0.0.1:$port"
@Before fun up() {
PhoneLink.initForTest(object : PhoneStore {
override fun all() = phones.toMap(); override fun get(id: String) = phones[id]
override fun put(id: String, value: String) { phones[id] = value }; override fun remove(id: String) { phones.remove(id) } })
PhoneLink.bindTo = InetAddress.getLoopbackAddress(); PhoneLink.port = port
PhoneLink.allowed = { it.isLoopbackAddress }; PhoneLink.mainDispatcher = Dispatchers.Unconfined
PhoneLink.pinNeeded = { false }
PhoneLink.handler = PhoneHandler(ChannelMaker({ d, _, _ -> listOf(Suggestion("x1", "The X-Files", 1993, "Series", "", "Science Fiction, 1990s")).filter { d.isNotBlank() } },
{ emptyList() }, { c, s -> made = c to s; "${c.name} is ready" }, { emptyList() }), { false }, { watched += it })
PhoneLink.start()
}
@After fun down() { PhoneLink.stop(); PhoneLink.handler = null; PhoneLink.allowed = EmbyServer::isHomeAddress; PhoneLink.bindTo = null; PhoneLink.port = PhoneLink.PORT; CustomChannels.clear() }
private class Reply(val status: Int, val body: String, val cookie: String?)
private fun call(path: String, method: String = "GET", body: String? = null, cookie: String? = null, form: Boolean = false, orbit: Boolean = true): Reply {
val c = URL(base + path).openConnection() as HttpURLConnection
c.instanceFollowRedirects = false; c.requestMethod = method
cookie?.let { c.setRequestProperty("Cookie", it) }
if (orbit && !form && method == "POST") c.setRequestProperty("X-Orbit", "1")
if (body != null) { c.doOutput = true; c.setRequestProperty("Content-Type", if (form) "application/x-www-form-urlencoded" else "application/json"); c.outputStream.use { it.write(body.toByteArray()) } }
val status = c.responseCode
val text = (if (status < 400) c.inputStream else c.errorStream)?.use { it.readBytes().toString(Charsets.UTF_8) }.orEmpty()
return Reply(status, text, c.getHeaderField("Set-Cookie")?.substringBefore(';')).also { c.disconnect() }
}
@Test fun onlyTheHomeNetworkIsAnsweredAndPrivateAddressesAreTheRule() {
assertTrue(EmbyServer.isHomeAddress(InetAddress.getByName("192.168.0.20"))); assertFalse(EmbyServer.isHomeAddress(InetAddress.getByName("8.8.8.8")))
assertFalse(EmbyServer.isHomeAddress(InetAddress.getLoopbackAddress()))
PhoneLink.allowed = { false }
assertThrows(java.io.IOException::class.java) { call("/") }
}
@Test fun aPhoneJoinsWithTheTvsCodeAndThenMakesAChannel() {
assertTrue(call("/").body.contains("Enter the code")) // not joined: the join page
assertTrue(call("/join", "POST", "code=0000", form = true).body.contains("run out")) // no code on the TV yet
val p = PhoneLink.beginPairing()
val wrong = if (p.code == "1111") "2222" else "1111"
assertTrue(call("/join", "POST", "code=$wrong", form = true).body.contains("Check the TV"))
val joined = call("/join", "POST", "code=${p.code}", form = true)
assertEquals(302, joined.status); val cookie = joined.cookie!!
assertEquals(1, phones.size); assertNull(PhoneLink.pairing)
assertTrue(call("/join", "POST", "code=${p.code}", form = true).body.contains("run out")) // once only
// The API, joined.
assertEquals(0, JSONObject(call("/api/channels", cookie = cookie).body).getJSONArray("channels").length())
assertEquals("The X-Files", JSONArray(call("/api/suggest?d=90s%20sci-fi", cookie = cookie).body).getJSONObject(0).getString("title"))
val body = JSONObject().put("name", "90s Sci-Fi").put("style", "Marathon").put("hours", JSONObject().put("from", 18).put("to", 0))
.put("shows", JSONArray().put(JSONObject().put("id", "x1").put("title", "The X-Files").put("year", 1993).put("type", "Series"))).toString()
assertEquals(403, call("/api/channels", "POST", body, cookie = cookie, orbit = false).status) // a cross-site form can't do it
assertEquals(200, call("/api/channels", "POST", body, cookie = cookie).status)
assertEquals(PlayStyle.Marathon, made!!.first.style); assertEquals(701, made!!.first.number); assertEquals(listOf("x1"), made!!.second.map { it.id })
assertEquals(200, call("/api/watch", "POST", """{"number":701}""", cookie = cookie).status); assertEquals(listOf(701), watched)
// Removed in Settings: the phone's cookie stops working (and with no phones left, the server stops altogether).
PhoneLink.forget(phones.keys.single())
assertTrue(runCatching { call("/api/channels", cookie = cookie).body.contains("Enter the code") }.getOrDefault(true))
}
@Test fun guessingRunsOutAndThePinIsAskedWhenThereIsOne() {
val p = PhoneLink.beginPairing()
val wrong = if (p.code == "1111") "2222" else "1111"
repeat(5) { call("/join", "POST", "code=$wrong", form = true) }
assertTrue(call("/join", "POST", "code=${p.code}", form = true).body.contains("Too many tries"))
assertTrue(phones.isEmpty())
PhoneLink.pinNeeded = { true }; PhoneLink.pinCheck = { it == "4321" }
val q = PhoneLink.beginPairing()
assertTrue(call("/join", "POST", "code=${q.code}", form = true).body.contains("Grown-ups PIN"))
assertTrue(call("/join", "POST", "code=${q.code}&pin=1234", form = true).body.contains("didn't match"))
assertEquals(302, call("/join", "POST", "code=${q.code}&pin=4321", form = true).status)
PhoneLink.pinNeeded = { false }
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 345 KiB

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB