diff --git a/android/app/src/main/assets/phone/companion.html b/android/app/src/main/assets/phone/companion.html index 555da79..5613381 100644 --- a/android/app/src/main/assets/phone/companion.html +++ b/android/app/src/main/assets/phone/companion.html @@ -157,7 +157,9 @@ function nowCard() { } function remote() { const q = state.now?.queue || []; - return head('Remote') + nowCard() + (q.length ? `
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.
` : ''; + return head('Remote') + away + nowCard() + (q.length ? `
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