Then from your queue: ${esc(q[0].title)}${q.length > 1 ? ` +${q.length - 1} more` : ''}
` : '') + `
+ // Orbit in the background on the TV (another app on screen): connected, but say so rather than seem broken.
+ const away = state.now && state.now.onTv === false ? `
Orbit isn't on screen on ${esc(room())}. Another app is showing. Open Orbit on the TV and the remote works again.
Then from your queue: ${esc(q[0].title)}${q.length > 1 ? ` +${q.length - 1} more` : ''}
` : '') + `
@@ -858,6 +860,9 @@ document.addEventListener('click', e => {
offerHome(); 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);
+// When the TV couldn't be reached, try again every few seconds, so the web app reconnects by itself (8 November 2026).
+setInterval(() => { if (!document.hidden && offline) load('now'); }, 4000);
+document.addEventListener('visibilitychange', () => { if (!document.hidden) load('now'); });
setInterval(() => { if (!document.hidden && ['guide','catchup','rooms'].includes(tab)) load(tab); }, 60000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) load('now'); });
diff --git a/android/app/src/main/java/tv/orbit/classic/CatchUp.kt b/android/app/src/main/java/tv/orbit/classic/CatchUp.kt
index 8d2e6c0..6ad0a69 100644
--- a/android/app/src/main/java/tv/orbit/classic/CatchUp.kt
+++ b/android/app/src/main/java/tv/orbit/classic/CatchUp.kt
@@ -151,7 +151,8 @@ internal fun catchUpMatches(entry: CatchUpEntry, channel: Int?, query: String):
internal fun CatchUpScreen(channels: List, session: EmbySampleSession?, modifier: Modifier = Modifier, onOpen: (CatchUpEntry) -> Unit,
onResume: (ContinueEntry) -> Unit = {}) {
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
- LaunchedEffect(Unit) { while (true) { delay(30_000); if (!FullScreen.active) now = System.currentTimeMillis() } }
+ val fullScreen = FullScreen.active
+ LaunchedEffect(fullScreen) { if (!fullScreen) now = System.currentTimeMillis(); while (true) { delay(30_000); if (!FullScreen.active) now = System.currentTimeMillis() } }
var channelFilter by remember { mutableStateOf(null) }
var query by remember { mutableStateOf("") }
var searching by remember { mutableStateOf(false) }
diff --git a/android/app/src/main/java/tv/orbit/classic/ContinueWatching.kt b/android/app/src/main/java/tv/orbit/classic/ContinueWatching.kt
index 445e6e8..a548bda 100644
--- a/android/app/src/main/java/tv/orbit/classic/ContinueWatching.kt
+++ b/android/app/src/main/java/tv/orbit/classic/ContinueWatching.kt
@@ -8,7 +8,8 @@ import org.json.JSONObject
import java.io.File
/**
- * Continue watching (Paul, 7 November 2026): where you left a programme part-way, from Catch Up or live, so Catch Up can
+ * Continue watching (Paul, 7 November 2026): where you left a programme part-way in Catch Up (live viewing isn't counted, since
+ * 8 November 2026), so Catch Up can
* carry it on from there while it's still in Catch Up. One list per profile, on this box only. It only reads the plans.
*/
internal class ContinueMark(val number: Int, val startMs: Long, val itemId: String, val positionMs: Long, val durationMs: Long, val atMs: Long) {
@@ -63,6 +64,12 @@ internal object ContinueWatching {
private var file: File? = null
fun load(scheduleDir: File, profile: String?) {
+ // Once: the lists made while live viewing counted too are set aside, now that only Catch Up adds to them.
+ val flag = File(scheduleDir, "continue-catchup-only")
+ if (!flag.exists()) runCatching {
+ scheduleDir.listFiles { f -> Regex("continue-[A-Za-z0-9]{1,40}\\.json").matches(f.name) }?.forEach { it.renameTo(File(scheduleDir, it.name + ".before-catchup-only")) }
+ flag.writeText("")
+ }
file = File(scheduleDir, "continue-${(profile ?: "all").filter { it.isLetterOrDigit() }.take(40)}.json")
marks = runCatching { JSONArray(file!!.readText()).let { a -> (0 until minOf(a.length(), CONTINUE_MAX)).mapNotNull { runCatching { ContinueMark.fromJson(a.getJSONObject(it)) }.getOrNull() } } }.getOrDefault(emptyList())
}
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 b72c7b5..31995cb 100644
--- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt
+++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt
@@ -327,7 +327,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
catch (_: Exception) { message = "Connected, but this setup could not be saved securely. It will need sign-in after restart." }
}
}
- LaunchedEffect(Unit) { while (true) { delay(1000); if (!FullScreen.active) now = System.currentTimeMillis() } }
+ val fullScreen = FullScreen.active
+ LaunchedEffect(fullScreen) { if (!fullScreen) now = System.currentTimeMillis(); while (true) { delay(1000); if (!FullScreen.active) now = System.currentTimeMillis() } }
LaunchedEffect(scheduledChannels) { windingDown = scheduledChannels[TOTS_NUMBER]?.stepIn?.let { it.endMs > System.currentTimeMillis() } == true }
fun smartLoop(programming: ChannelProgramming) = (extraChannels + listOfNotNull(channel, afterDarkChannel, toonamiChannel)).firstOrNull { it.number == programming.number }
LaunchedEffect(session != null) { if (session != null) onSignedIn() }
@@ -380,7 +381,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
val now = System.currentTimeMillis(); val at = now + channelClockOffset
val show = runCatching { channel.at(at) }.getOrNull() ?: continue
if (show.endMs - show.startMs < 10 * 60_000L) continue
- runCatching { withContext(Dispatchers.IO) { ContinueWatching.note(channel.number, show, at - show.startMs, now) } }
+ // Continue watching is for Catch Up only (Paul, 8 November 2026): what you watch live isn't added to it.
+ if (channelClockOffset < 0) runCatching { withContext(Dispatchers.IO) { ContinueWatching.note(channel.number, show, at - show.startMs, now) } }
runCatching { withContext(Dispatchers.IO) { Taste.watching(channel.number, show, channel.seriesIdAt(show.startMs), at - show.startMs, now) } }
}
}
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 2675fa6..dc2069d 100644
--- a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt
+++ b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt
@@ -46,7 +46,7 @@ internal fun clock(time: Long) = SimpleDateFormat("HH:mm", Locale.UK).format(Dat
class MainActivity : ComponentActivity() {
override fun onStart() {
- super.onStart(); if (PhoneLink.phones.isNotEmpty()) PhoneLink.start()
+ super.onStart(); OrbitCompanion.onTv = true; if (PhoneLink.phones.isNotEmpty()) PhoneLink.start()
// Multiroom auto-scan: a set-up Orbit can be found by a new TV while it's open.
if (Onboarding.done(this)) {
RoomBeacon.on(this)
@@ -54,7 +54,9 @@ class MainActivity : ComponentActivity() {
lifecycleScope.launch { runCatching { RoomUpdates.look(this@MainActivity) } }
}
}
- override fun onStop() { RoomBeacon.off(this); PhoneLink.offerRoom(null); PhoneLink.stop(); super.onStop() }
+ // Phones stay connected while Orbit is in the background (Paul, 8 November 2026: the web app couldn't connect while the TV
+ // was in another app), and paired rooms keep syncing; the phone says Orbit isn't on screen instead of failing to load.
+ override fun onStop() { OrbitCompanion.onTv = false; PhoneLink.offerRoom(null); if (PhoneLink.phones.isEmpty() && RoomLinks.links.none { it.on }) { RoomBeacon.off(this); PhoneLink.stop() }; super.onStop() }
override fun onCreate(savedInstanceState: Bundle?) {
OrbitCompanion.activity = java.lang.ref.WeakReference(this)
super.onCreate(savedInstanceState)
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 ecf2fd4..0779969 100644
--- a/android/app/src/main/java/tv/orbit/classic/OrbitCompanion.kt
+++ b/android/app/src/main/java/tv/orbit/classic/OrbitCompanion.kt
@@ -122,6 +122,8 @@ internal object OrbitCompanion {
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. */
+ /** Orbit is on the TV's screen; false while the TV shows another app (phones and paired rooms still connect). */
+ @Volatile var onTv = true
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)
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 3ab113f..3a47321 100644
--- a/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt
+++ b/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt
@@ -491,7 +491,7 @@ internal object PhoneLink {
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).put("queue", OrbitCompanion.queueJson()))
+ "GET /api/now" -> json(out, 200, OrbitCompanion.now(s, now).put("queue", OrbitCompanion.queueJson()).put("onTv", OrbitCompanion.onTv))
"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))
diff --git a/android/app/src/main/java/tv/orbit/classic/Ticker.kt b/android/app/src/main/java/tv/orbit/classic/Ticker.kt
index e421f05..7aa3fc3 100644
--- a/android/app/src/main/java/tv/orbit/classic/Ticker.kt
+++ b/android/app/src/main/java/tv/orbit/classic/Ticker.kt
@@ -15,7 +15,10 @@ import kotlinx.coroutines.delay
@Composable
internal fun rememberMinuteClock(): State {
val now = remember { mutableLongStateOf(System.currentTimeMillis()) }
- LaunchedEffect(Unit) {
+ // Back from full screen: the time is put right at once, not when the next minute turns (Paul, 8 November 2026).
+ val full = FullScreen.active
+ LaunchedEffect(full) {
+ if (!full) now.longValue = System.currentTimeMillis()
while (true) {
val at = System.currentTimeMillis()
delay(MINUTE_MS - at % MINUTE_MS + 50)
diff --git a/android/app/src/main/res/raw/ident_304.mp4 b/android/app/src/main/res/raw/ident_304.mp4
index 66b0232..e02f8a4 100644
Binary files a/android/app/src/main/res/raw/ident_304.mp4 and b/android/app/src/main/res/raw/ident_304.mp4 differ
diff --git a/android/app/src/main/res/raw/ident_306.mp4 b/android/app/src/main/res/raw/ident_306.mp4
index 9699e16..39cc6e0 100644
Binary files a/android/app/src/main/res/raw/ident_306.mp4 and b/android/app/src/main/res/raw/ident_306.mp4 differ
diff --git a/android/app/src/test/java/tv/orbit/classic/ContinueCatchUpOnlyTest.kt b/android/app/src/test/java/tv/orbit/classic/ContinueCatchUpOnlyTest.kt
new file mode 100644
index 0000000..42955cd
--- /dev/null
+++ b/android/app/src/test/java/tv/orbit/classic/ContinueCatchUpOnlyTest.kt
@@ -0,0 +1,20 @@
+package tv.orbit.classic
+
+import org.junit.Assert.*
+import org.junit.Test
+import java.nio.file.Files
+
+/** 8 November 2026: the lists made while live viewing counted are set aside once, and a new list then stays. */
+class ContinueCatchUpOnlyTest {
+ @Test fun oldListsAreSetAsideOnceOnly() {
+ val dir = Files.createTempDirectory("cw").toFile()
+ val mark = ContinueMark(304, 1000, "abc123", 600_000, 3_600_000, 2000)
+ java.io.File(dir, "continue-grown.json").writeText(org.json.JSONArray(listOf(mark.toJson())).toString())
+ ContinueWatching.load(dir, "grown")
+ assertTrue(ContinueWatching.marks.isEmpty())
+ assertTrue(java.io.File(dir, "continue-grown.json.before-catchup-only").exists())
+ java.io.File(dir, "continue-grown.json").writeText(org.json.JSONArray(listOf(mark.toJson())).toString())
+ ContinueWatching.load(dir, "grown")
+ assertEquals(1, ContinueWatching.marks.size)
+ }
+}
diff --git a/design/channels/sound/build-samples.py b/design/channels/sound/build-samples.py
new file mode 100644
index 0000000..e831b5a
--- /dev/null
+++ b/design/channels/sound/build-samples.py
@@ -0,0 +1,29 @@
+"""Lays Paul's royalty-free samples (samples/README.md) into the VHS and Horror stings. deliver.py then masters them to the standard.
+Run: python3 design/channels/sound/build-samples.py"""
+import shutil, subprocess
+from pathlib import Path
+
+HERE = Path(__file__).parent; LIB = HERE / "library"; S = HERE / "samples"
+
+def mix(key, graph, *inputs):
+ exp = LIB / key / "export"; synth = exp / "sting-synth.wav"
+ if not synth.exists(): shutil.copy(exp / "sting.wav", synth)
+ args = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", str(synth)]
+ for i in inputs: args += ["-i", str(i)]
+ subprocess.run(args + ["-filter_complex", graph, "-map", "[out]", "-ar", "48000", "-ac", "2", "-t", "7", str(exp / "sting.wav")], check=True)
+
+# VHS: the cassette clunks in, the synthwave starts a beat later, and it clicks out as the card goes.
+mix("ch306",
+ "[1:a]atrim=5.45:6.45,asetpts=PTS-STARTPTS,aformat=channel_layouts=stereo,highpass=f=80,volume=1.1[in];"
+ "[1:a]atrim=7.45:8.40,asetpts=PTS-STARTPTS,aformat=channel_layouts=stereo,highpass=f=80,volume=1.3,adelay=6050|6050[out1];"
+ "[0:a]atrim=0:6.7,asetpts=PTS-STARTPTS,adelay=300|300,afade=t=out:st=6.2:d=0.8[syn];"
+ "[syn][in][out1]amix=inputs=3:normalize=0:duration=longest[out]",
+ S / "vhs-cassette.mp3")
+
+# Horror: a scream far off down a corridor, slowed and dulled, rising under the drone and gone before the end. Restrained, not a jump.
+mix("ch304",
+ "[1:a]atrim=11.5:16.5,asetpts=PTS-STARTPTS,asetrate=48000*0.86,aresample=48000,lowpass=f=2600,highpass=f=180,"
+ "aecho=0.8:0.7:180|340:0.45|0.3,afade=t=in:d=2.2,afade=t=out:st=3.6:d=1.8,volume=0.85,adelay=1500|1500[scr];"
+ "[0:a][scr]amix=inputs=2:normalize=0:duration=first[out]",
+ S / "horror-scream.mp3")
+print("ch306 and ch304 stings rebuilt with samples")
diff --git a/design/channels/sound/library/ch304/export/sting-synth.wav b/design/channels/sound/library/ch304/export/sting-synth.wav
new file mode 100644
index 0000000..105b982
Binary files /dev/null and b/design/channels/sound/library/ch304/export/sting-synth.wav differ
diff --git a/design/channels/sound/library/ch304/export/sting.wav b/design/channels/sound/library/ch304/export/sting.wav
index 105b982..5398f17 100644
Binary files a/design/channels/sound/library/ch304/export/sting.wav and b/design/channels/sound/library/ch304/export/sting.wav differ
diff --git a/design/channels/sound/library/ch306/export/sting-synth.wav b/design/channels/sound/library/ch306/export/sting-synth.wav
new file mode 100644
index 0000000..4b7d4cd
Binary files /dev/null and b/design/channels/sound/library/ch306/export/sting-synth.wav differ
diff --git a/design/channels/sound/library/ch306/export/sting.wav b/design/channels/sound/library/ch306/export/sting.wav
index 4b7d4cd..a966a2e 100644
Binary files a/design/channels/sound/library/ch306/export/sting.wav and b/design/channels/sound/library/ch306/export/sting.wav differ
diff --git a/design/channels/sound/library/library.json b/design/channels/sound/library/library.json
index 87f4f43..a430405 100644
--- a/design/channels/sound/library/library.json
+++ b/design/channels/sound/library/library.json
@@ -289,7 +289,8 @@
"source": "ch304/ch304.scraft",
"commands": "ch304/ch304.commands.txt",
"exports": {
- "sting": "ch304/export/sting.wav"
+ "sting": "ch304/export/sting.wav",
+ "synth": "ch304/export/sting-synth.wav"
},
"delivered": "android/app/src/main/res/raw/ident_304.mp4",
"variants": {
@@ -297,7 +298,10 @@
"opening": null,
"seasonal": null,
"calm": null
- }
+ },
+ "samples": [
+ "samples/horror-scream.mp3"
+ ]
},
"ch305": {
"identity": "After Dark: smoky late-night ambience, a subtle bass, mysterious tones (no snare; barely-there brushes).",
@@ -319,7 +323,8 @@
"source": "ch306/ch306.scraft",
"commands": "ch306/ch306.commands.txt",
"exports": {
- "sting": "ch306/export/sting.wav"
+ "sting": "ch306/export/sting.wav",
+ "synth": "ch306/export/sting-synth.wav"
},
"delivered": "android/app/src/main/res/raw/ident_306.mp4",
"variants": {
@@ -327,7 +332,10 @@
"opening": null,
"seasonal": null,
"calm": null
- }
+ },
+ "samples": [
+ "samples/vhs-cassette.mp3"
+ ]
},
"ch307": {
"identity": "Orbit Romance: soft strings and the piano phrase, intimate.",
diff --git a/design/channels/sound/samples/README.md b/design/channels/sound/samples/README.md
new file mode 100644
index 0000000..fc52434
--- /dev/null
+++ b/design/channels/sound/samples/README.md
@@ -0,0 +1,10 @@
+# Samples
+
+Royalty-free sound effects Paul downloaded from Pixabay (8 November 2026), used inside Orbit's idents after mastering.
+
+| File | Original | Used in |
+|---|---|---|
+| vhs-cassette.mp3 | freesound_community-60_vhs_casette_out-86997.mp3 | ch306 Orbit VHS: cassette in at the start, out at the end |
+| horror-scream.mp3 | jusatti890-scream-horror-sfx-490916.mp3 | ch304 Orbit Horror: a distant, lowered, filtered scream rising under the drone |
+
+Built by `build-samples.py`; the synth-only stings are kept as `export/sting-synth.wav`.
diff --git a/design/channels/sound/samples/horror-scream.mp3 b/design/channels/sound/samples/horror-scream.mp3
new file mode 100644
index 0000000..de52dec
Binary files /dev/null and b/design/channels/sound/samples/horror-scream.mp3 differ
diff --git a/design/channels/sound/samples/vhs-cassette.mp3 b/design/channels/sound/samples/vhs-cassette.mp3
new file mode 100644
index 0000000..e60a1f5
Binary files /dev/null and b/design/channels/sound/samples/vhs-cassette.mp3 differ