diff --git a/VERIFICATION-0.81.0.md b/VERIFICATION-0.81.0.md new file mode 100644 index 0000000..2de3b0c --- /dev/null +++ b/VERIFICATION-0.81.0.md @@ -0,0 +1,34 @@ +# Orbit TV 0.81.0: Orbit Tots + +Built, tested and linted. Not installed yet, so nothing below marked "on the box" has been checked. + +## What's in it + +- Orbit Tots on 614, with hand-approved shows: Morning 06:00 to 09:00, Playtime 09:00 to 17:00, Wind Down 17:00 to 21:00, Night 21:00 to 06:00. +- Shuffled episodes, per channel. Only Tots uses it; every other channel stays in season order. +- Hello, TOTS! and Sleepy TOTS idents, chosen by the next programme's start time. Jingle at most every 30 minutes, in Morning and Wind Down only. +- A TOTS-only bug, a nightcap planet on the evening tuning screen, and still frames. +- Channel logo, Idents and Ident sound switches under Watching. +- Wind Down Now under Watching, with a confirmation. Its plan is saved apart from Tots' own and ends by the next 06:00. +- Drive-In reminders held back while watching Tots from 17:00 to 06:00. + +## Measured + +- Unit tests: 306 passed, 0 failed (12 new in TotsTest). Lint passed. +- APK 27,225,636 bytes, up about 0.77 MB. Tots assets 752,147 bytes against the 1.5 MB target. +- Morning film 331,865 bytes, 5.0 s, -23.3 LUFS, peak -8.8 dBFS, average brightness 180 to 189 of 255, largest frame-to-frame change 0.51. +- Evening film 339,386 bytes, 6.0 s, -30.0 LUFS, peak -17.7 dBFS, average brightness 44 to 94 of 255, largest frame-to-frame change 4.48, during its slow fades. + +## Still to prove on the box + +- The title list finds each show, and each has English episodes that play. +- Morning and evening changeovers, with the jingle balanced against real programme sound. +- Logo, ident and sound switches. +- Wind Down Now on and off, including across a restart. + +## Known limits + +- No child profiles, PIN or lock yet, so a child can change channel from Tots. Paul accepted this for now; that work is next. +- Wind Down Now's stop takes effect at once, after a confirmation. Its start waits for the current programme. +- The jingle's 30 minute gap resets if the app restarts. +- Fimbles isn't in the library. Rollback: reinstall 0.80.0; turning Tots off removes it from the guide. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f29158a..edc4b03 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -9,8 +9,8 @@ android { applicationId = "tv.orbit.classic" minSdk = 26 targetSdk = 36 - versionCode = 90 - versionName = "0.80.0-blender-idents" + versionCode = 91 + versionName = "0.81.0-orbit-tots" } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt b/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt index b0fe97f..d92f6e0 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt @@ -14,6 +14,7 @@ internal object ChannelBrands { 308 to ChannelBranding("orbit-detour", "Orbit Detour", R.drawable.orbit_detour_epg, R.drawable.orbit_one_background, R.drawable.orbit_detour_bug, ChannelCategory.Movies), 309 to ChannelBranding("orbit-britx", "Orbit BritX", R.drawable.orbit_britx_epg, R.drawable.orbit_one_background, R.drawable.orbit_britx_bug, ChannelCategory.Movies), 611 to ChannelBranding("orbit-junior", "Orbit Junior", R.drawable.orbit_junior_epg, R.drawable.orbit_one_background, R.drawable.orbit_junior_bug, ChannelCategory.Kids), + TOTS_NUMBER to ChannelBranding("orbit-tots", "Orbit Tots", R.drawable.orbit_tots_epg, R.drawable.orbit_one_background, R.drawable.orbit_tots_bug, ChannelCategory.Kids), 203 to ChannelBranding("orbit-wild", "Orbit Wild", R.drawable.orbit_wild_epg, R.drawable.orbit_one_background, R.drawable.orbit_wild_bug, ChannelCategory.Documentaries), 102 to ChannelBranding("orbit-2", "Orbit 2", R.drawable.orbit_2_epg, R.drawable.orbit_one_background, R.drawable.orbit_2_bug, ChannelCategory.Entertainment), 612 to ChannelBranding("wonder", "Orbit Wonder", R.drawable.wonder_epg, R.drawable.orbit_one_background, R.drawable.wonder_bug, ChannelCategory.Kids), diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt index bdb2791..2643a38 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt @@ -19,7 +19,9 @@ internal data class ChannelProgramming(val number: Int, val name: String, val se val dayParts: List = emptyList(), val suggestionRules: List = emptyList(), /** Leave out episodes with no English sound. Off where subtitled shows belong, like Vortex's anime. */ - val englishAudio: Boolean = true) { + val englishAudio: Boolean = true, + /** Each show's episodes in a mixed order, all of them before any repeats. Everything else runs in season order. */ + val shuffleEpisodes: Boolean = false) { /** Changes when the list does, so the episode cache is read again. What's already committed stays. */ val signature get() = "$CHANNEL_POOL_VERSION#${searches.joinToString("|")}#$seriesCount" } @@ -58,6 +60,7 @@ internal val SmartChannels = listOf( "Life on Earth", "The Living Planet", "The Trials of Life", "Our Planet", "Our Planet II", "Dynasties", "Dynasties II", "Seven Worlds, One Planet", "A Perfect Planet", "Hostile Planet", "Africa", "Wild China", "South Pacific", "Great Barrier Reef", "The Hunt", "The Life of Mammals", "The Life of Birds", "Nature's Great Events", "Green Planet", "Prehistoric Planet"), seriesCount = 20, finishTogether = true, exactTitles = true), + ChannelProgramming(TOTS_NUMBER, "Tots", TotsShows, seriesCount = 40, finishTogether = true, exactTitles = true, dayParts = TotsDayParts, shuffleEpisodes = true), ChannelProgramming(106, "Paradox", ChannelPresets.first { it.number == 106 }.searches)) + ChannelPresets.filter { it.smartOnly }.map { ChannelProgramming(it.number, it.name, it.searches, seriesCount = if (it.films) it.searches.size else 60, finishTogether = !it.films, exactTitles = true, filmRestMs = if (it.films) 7 * 24 * 60 * 60_000L else null, @@ -123,6 +126,9 @@ internal const val SCHEDULE_KEEP_PAST_MS = 24 * 60 * 60_000L internal fun nextEpisode(series: PoolSeries, last: EpisodeMark?): PoolEpisode = last?.let { mark -> series.episodes.firstOrNull { it.mark > mark } } ?: series.episodes.first() +/** The same mix every time, so a place in it can be saved as an episode. */ +internal fun shuffledEpisodes(series: PoolSeries): List = series.episodes.shuffled(java.util.Random(series.id.hashCode().toLong())) + /** * What a programme starting at [startMs] may use, by the local hour then, so the clocks changing moves blocks with the wall clock. * A block with nothing in the pool leaves the whole pool, so a channel never goes off air for lack of a title. @@ -195,21 +201,28 @@ internal fun extendFilmSchedule(pool: List, cursor: ScheduleCursor, /** Shows take turns, one episode each, back to back from [fromMs] until [untilMs] is covered. */ internal fun extendSchedule(pool: List, cursor: ScheduleCursor, fromMs: Long, untilMs: Long, filmRestMs: Long? = null, finishTogether: Boolean = false, - dayParts: List = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }): ChannelPlan { + dayParts: List = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }, shuffle: Boolean = false): ChannelPlan { require(pool.isNotEmpty() && pool.all { it.episodes.isNotEmpty() }) if (filmRestMs != null) return extendFilmSchedule(pool, cursor, fromMs, untilMs, filmRestMs, dayParts, zone, avoid) val marks = cursor.marks.toMutableMap() val entries = mutableListOf() var last = cursor.lastSeriesId var start = fromMs + val mixes = HashMap>() + fun running(show: PoolSeries) = if (shuffle) mixes.getOrPut(show.id) { shuffledEpisodes(show) } else show.episodes + fun following(show: PoolSeries): PoolEpisode { + if (!shuffle) return nextEpisode(show, marks[show.id]) + val mix = running(show) + return mix[(mix.indexOfFirst { it.mark == marks[show.id] } + 1) % mix.size] + } while (start < untilMs) { val available = dayPartPool(pool, dayParts, start, zone) // Shows only wait for others in the same block, or a daytime show could stand idle for an evening one. - if (finishTogether && available.all { marks[it.id] == it.episodes.last().mark }) available.forEach { marks.remove(it.id) } - val allowed = { show: PoolSeries -> show in available && (!finishTogether || marks[show.id] != show.episodes.last().mark) } + if (finishTogether && available.all { marks[it.id] == running(it).last().mark }) available.forEach { marks.remove(it.id) } + val allowed = { show: PoolSeries -> show in available && (!finishTogether || marks[show.id] != running(show).last().mark) } // Skips a show already on elsewhere, unless that would leave nothing to show. - val series = turnOrder(pool, last).firstOrNull { allowed(it) && !avoid(it, start, start + nextEpisode(it, marks[it.id]).durationMs) } ?: nextInTurn(pool, last, allowed) - val episode = nextEpisode(series, marks[series.id]) + val series = turnOrder(pool, last).firstOrNull { allowed(it) && !avoid(it, start, start + following(it).durationMs) } ?: nextInTurn(pool, last, allowed) + val episode = following(series) val item = if (series.isFilm) SampleItem(series.title, "Movie", episode.durationMs / 60_000, "", episode.id, episode.durationMs) else scheduledItem(series.title, episode) val media = ScheduledMedia(item, episode.sourceId, episode.durationMs) entries += ScheduleEntry(series.id, episode.mark, media, start) @@ -241,12 +254,12 @@ internal fun airedCursor(base: ScheduleCursor, entries: List, now * With no usable plan it starts from now at the aired position. */ internal fun topUpPlan(existing: ChannelPlan?, aired: ScheduleCursor, pool: List?, nowMs: Long, filmRestMs: Long? = null, finishTogether: Boolean = false, - dayParts: List = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }): ChannelPlan? { + dayParts: List = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }, shuffle: Boolean = false): ChannelPlan? { val kept = existing?.let { plan -> plan.copy(entries = plan.entries.dropWhile { it.endMs < nowMs - SCHEDULE_KEEP_PAST_MS }) } if (kept != null && kept.endMs >= nowMs + SCHEDULE_TOP_UP_MS) return kept if (pool.isNullOrEmpty()) return kept val from = if (kept != null && kept.endMs > nowMs) kept.endMs else nowMs - val added = extendSchedule(pool, existing?.tail ?: aired, from, nowMs + SCHEDULE_AHEAD_MS, filmRestMs, finishTogether, dayParts, zone, avoid) + val added = extendSchedule(pool, existing?.tail ?: aired, from, nowMs + SCHEDULE_AHEAD_MS, filmRestMs, finishTogether, dayParts, zone, avoid, shuffle) return ChannelPlan(kept?.entries.orEmpty() + added.entries, added.tail) } @@ -256,8 +269,11 @@ internal fun topUpPlan(existing: ChannelPlan?, aired: ScheduleCursor, pool: List */ internal class ScheduledChannel(val loop: SampleChannel, plan: ChannelPlan) : SampleChannel(loop.lineup, loop.name, loop.number) { @Volatile var plan: ChannelPlan = plan - fun seriesIdAt(startMs: Long) = plan.entries.firstOrNull { it.startMs == startMs }?.seriesId + /** Runs over the plan for a while, as Wind Down Now does, leaving the plan itself as it was. */ + @Volatile var stepIn: ChannelPlan? = null + fun seriesIdAt(startMs: Long) = (stepIn?.entries.orEmpty() + plan.entries).firstOrNull { it.startMs == startMs }?.seriesId override fun at(timeMs: Long): SampleBroadcast { + stepIn?.entries?.firstOrNull { timeMs >= it.startMs && timeMs < it.endMs }?.let { return SampleBroadcast(it.media, it.startMs) } val entries = plan.entries var low = 0 var high = entries.lastIndex diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt index 121dbc9..359b9e6 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt @@ -15,9 +15,25 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl fun load(userId: String, loop: SampleChannel): ScheduledChannel? { if (store.loadProgress(userId, loop.number)?.enabled != true) return null val plan = store.loadPlan(userId, loop.number)?.takeIf { it.entries.isNotEmpty() } ?: return null - return ScheduledChannel(loop, plan) + return ScheduledChannel(loop, plan).also { if (it.number == TOTS_NUMBER) it.stepIn = windDownPlan(userId) } } + /** Wind Down Now's plan, if one is still running. */ + fun windDownPlan(userId: String): ChannelPlan? = store.loadPlan(userId, TOTS_WIND_DOWN_FILE)?.takeIf { it.entries.isNotEmpty() && it.endMs > clock() } + + /** Calm shows from [fromMs], the end of what's on now, until morning. Tots' own plan isn't touched, so stopping just removes this. */ + fun startWindDown(userId: String, fromMs: Long): ChannelPlan? { + val calm = TotsWindDown.map(::seriesKey).toSet() + val pool = withoutFailed(store.loadPool(userId, TOTS_NUMBER)?.series.orEmpty().filter { seriesKey(it.title) in calm }, store.loadFailed(userId)) + if (pool.isEmpty()) return null + val others = clashesWith((SmartChannels.map { it.number }.filter { it != TOTS_NUMBER } + DRIVE_IN_NUMBER).mapNotNull { store.loadPlan(userId, it) }) + val plan = extendSchedule(pool, store.loadProgress(userId, TOTS_NUMBER)?.aired ?: ScheduleCursor(), fromMs, totsWindDownEnd(fromMs), finishTogether = true, avoid = others, shuffle = true) + store.savePlan(userId, TOTS_WIND_DOWN_FILE, plan) + return plan + } + + fun stopWindDown(userId: String) = store.savePlan(userId, TOTS_WIND_DOWN_FILE, ChannelPlan(emptyList(), ScheduleCursor())) + fun enabled(userId: String, number: Int) = store.loadProgress(userId, number)?.enabled == true /** Going back to the loop keeps the schedule and the place, so turning it on again carries on. */ @@ -89,7 +105,7 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl // Other channels' plans are only read if this one is actually about to grow. val others by lazy { clashesWith((SmartChannels.map { it.number }.filter { it != number } + DRIVE_IN_NUMBER).mapNotNull { store.loadPlan(userId, it) }) } val plan = topUpPlan(existing, base, pool?.series?.let { withoutFailed(it, store.loadFailed(userId)) }, now, programming.filmRestMs, programming.finishTogether, programming.dayParts, - avoid = { show, startMs, endMs -> others(show, startMs, endMs) }) ?: throw EmbyCheckException("${programming.name} has no episodes to schedule.") + avoid = { show, startMs, endMs -> others(show, startMs, endMs) }, shuffle = programming.shuffleEpisodes) ?: throw EmbyCheckException("${programming.name} has no episodes to schedule.") if (plan !== existing) store.savePlan(userId, number, plan) store.saveProgress(userId, number, ScheduleStore.SavedProgress(progress?.enabled ?: true, airedCursor(base, plan.entries, now))) session.rememberScheduledItems(plan.entries.map { it.media.item }) diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelTuningBackdrop.kt b/android/app/src/main/java/tv/orbit/classic/ChannelTuningBackdrop.kt index 9c88030..00c8912 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelTuningBackdrop.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelTuningBackdrop.kt @@ -21,6 +21,8 @@ internal fun ChannelTuningBackdrop(number: Int, modifier: Modifier = Modifier, a Image(painterResource(branding.logo), null, Modifier.align(Alignment.TopStart).padding(start = 40.dp, top = 32.dp).width(200.dp).height(62.dp)) } + if (number == TOTS_NUMBER && totsMood(System.currentTimeMillis()).evening) + Image(painterResource(R.drawable.orbit_tots_sleepy), null, Modifier.align(Alignment.Center).size(180.dp)) ClassicTuningNumber(number, Modifier.align(Alignment.TopEnd).padding(top = 32.dp, end = 44.dp)) Column(Modifier.align(Alignment.BottomStart).padding(start = 40.dp, bottom = 36.dp).background(Navy.copy(alpha = .78f)).padding(horizontal = 12.dp, vertical = 6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Text("Tuning…", color = Muted, fontSize = 17.sp) diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt index 44a69de..de3fb50 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -66,7 +66,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI connection.connectTimeout = 10_000 connection.readTimeout = request.readTimeoutMs ?: if (request.path == "/Sessions/Logout") 5_000 else 15_000 connection.setRequestProperty("Accept", "application/json") - connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.80.0\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.81.0\"") request.token?.let { connection.setRequestProperty("X-Emby-Token", it) } if (request.method == "POST") { val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8) 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 5a11943..dccceb4 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -88,6 +88,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea var tickets by remember { mutableStateOf>(emptyMap()) } var pendingDriveInWatch by remember { mutableStateOf(false) } var reminder by remember { mutableStateOf(null) } + var windingDown by remember { mutableStateOf(false) } // Each visit opens on the next of the three scenes. var driveInVisits by remember { mutableIntStateOf((System.currentTimeMillis() / 60_000 % DRIVE_IN_SCENES).toInt()) } var guideCategory by remember { mutableStateOf(ChannelCategory.All) } @@ -236,6 +237,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea } } LaunchedEffect(Unit) { while (true) { now = System.currentTimeMillis(); delay(1000) } } + 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 } // Keeps smart channels topped up in the background. A failure changes nothing on air. LaunchedEffect(session, extraChannels, channel, afterDarkChannel, toonamiChannel) { @@ -292,7 +294,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea val booked = driveInPlan while (booked != null) { val time = System.currentTimeMillis() - val due = driveInReminderDue(driveInBills(booked), tickets, time) + // Nothing pops up over Tots once it's winding down for the night. + val due = driveInReminderDue(driveInBills(booked), tickets, time).takeUnless { watchingChannel && selectedNumber == TOTS_NUMBER && totsMood(time).evening } // Already watching it, so there's nothing to remind about. if (due != null && due.stage == 2 && watchingDriveIn) saveTickets(tickets + (due.bill.startMs to 2)) else if (due?.stage != reminder?.stage || due?.bill?.startMs != reminder?.bill?.startMs) reminder = due delay(5_000) @@ -451,7 +454,16 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea if (session != null) { SettingsScreen(settingsSection, { settingsSection = it }, modifier, busy, message, HttpsEmbyTransport.SERVER, sample?.total, isOn = { scheduledChannels.containsKey(it) }, onSwitch = { programming, on -> setSmartSchedule(programming, on) }, onSuggestions = { openSuggestions(it) }, - onSignOut = { job?.cancel(); closeSession(forget = true) }) + onSignOut = { job?.cancel(); closeSession(forget = true) }, windingDown = windingDown, onWindDown = { on -> + val tots = scheduledChannels[TOTS_NUMBER] + val active = session + if (tots != null && active != null) scope.launch(Dispatchers.IO) { + runCatching { + tots.stepIn = if (on) scheduler.startWindDown(active.accountId, tots.at(System.currentTimeMillis()).endMs) else { scheduler.stopWindDown(active.accountId); null } + windingDown = tots.stepIn != null + } + } + }) return } Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt index 546dedb..01d3050 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt @@ -45,6 +45,7 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl /** Set while an ident covers the end of the show: drawn over everything, with the show itself quietened underneath. */ ident: (@Composable () -> Unit)? = null) { val context = LocalContext.current + val logoSetting = remember { OrbitSettings(context).channelLogo } var failure by remember { mutableStateOf(null) } var tuningNumberVisible by remember { mutableStateOf(announce) } var ready by remember { mutableStateOf(false) } @@ -316,7 +317,7 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl ClassicTuningNumber(scheduled.channel.number, Modifier.align(Alignment.TopEnd).padding(top = 32.dp, end = 44.dp)) // The channel's mark, off while the programme bar is up. It's drawn over the picture, never into it. - val bug = scheduled?.let { ChannelBrands.forNumber(it.channel.number)?.bug } + val bug = scheduled?.let { ChannelBrands.forNumber(it.channel.number)?.bug }?.takeIf { logoSetting } if (bug != null && ready && !infoVisible && failure == null) Image(painterResource(bug), null, Modifier.align(Alignment.BottomStart).padding(start = 40.dp, bottom = 28.dp).width(108.dp).height(36.dp), alpha = .8f) if (infoVisible && failure == null && ready) ClassicProgrammeStrip(plan.item, playing, diff --git a/android/app/src/main/java/tv/orbit/classic/Idents.kt b/android/app/src/main/java/tv/orbit/classic/Idents.kt index c26e387..687a3ad 100644 --- a/android/app/src/main/java/tv/orbit/classic/Idents.kt +++ b/android/app/src/main/java/tv/orbit/classic/Idents.kt @@ -37,8 +37,13 @@ private val IdentVideos = mapOf(101 to R.raw.ident_101, 102 to R.raw.ident_102, 306 to R.raw.ident_306, 307 to R.raw.ident_307, 308 to R.raw.ident_308, 309 to R.raw.ident_309, 401 to R.raw.ident_401, 601 to R.raw.ident_601, 602 to R.raw.ident_602, 603 to R.raw.ident_603, 604 to R.raw.ident_604, 605 to R.raw.ident_605, 611 to R.raw.ident_611, 612 to R.raw.ident_612, 613 to R.raw.ident_613) -internal fun identVideo(number: Int) = IdentVideos[number] -internal fun identLength(number: Int) = if (number in IdentVideos) IDENT_VIDEO_MS else IDENT_STING_MS +/** [atMs] is when the next programme starts, for a channel whose ident follows the time of day. */ +internal fun identVideo(number: Int, atMs: Long = 0) = if (number == TOTS_NUMBER) totsMood(atMs).video else IdentVideos[number] +internal fun identLength(number: Int, atMs: Long = 0) = when (number) { + TOTS_NUMBER -> totsMood(atMs).lengthMs + in IdentVideos -> IDENT_VIDEO_MS + else -> IDENT_STING_MS +} internal enum class IdentPhase { None, LoadNext, Showing } @@ -78,16 +83,21 @@ internal fun loadNextPlayer(context: Context, session: EmbySampleSession, plan: @Composable internal fun IdentOverlay(session: EmbySampleSession, channel: SampleChannel, next: SampleBroadcast, identMs: Long, startedLateMs: Long) { val context = LocalContext.current + val mood = if (channel.number == TOTS_NUMBER) totsMood(next.startMs) else null + val settings = remember { OrbitSettings(context) } val fade = remember { Animatable(0f) } - LaunchedEffect(Unit) { fade.animateTo(1f, tween(400)) } + LaunchedEffect(Unit) { fade.animateTo(1f, tween(mood?.fadeMs ?: 400)) } Box(Modifier.fillMaxSize().graphicsLayer { alpha = fade.value }.background(Navy)) { - val video = identVideo(channel.number) - if (video != null) { + val video = identVideo(channel.number, next.startMs) + // With idents off, Tots still says goodbye with a picture that doesn't move. + if (mood != null && !settings.idents) Image(painterResource(mood.still), null, Modifier.fillMaxSize()) + else if (video != null) { val player = remember(video) { ExoPlayer.Builder(context).build().apply { setMediaItem(MediaItem.fromUri(RawResourceDataSource.buildRawResourceUri(video))) seekTo(startedLateMs.coerceIn(0, identMs)) - volume = 0f; prepare(); playWhenReady = true + volume = if (mood != null && settings.identSound && TotsJingle.take(mood, System.currentTimeMillis())) 1f else 0f + prepare(); playWhenReady = true } } DisposableEffect(player) { onDispose { player.release() } } diff --git a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt index 9e36671..bb2ab57 100644 --- a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt +++ b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt @@ -53,8 +53,9 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC else { stream?.discard(session); null } } val next = shows[1] - val identMs = identLength(channel.number) - val identsHere = channel is ScheduledChannel && clockOffsetMs == 0L && channel.number != DRIVE_IN_NUMBER && next.startMs == current.endMs + val identMs = identLength(channel.number, next.startMs) + val identsOn = remember { OrbitSettings(settingsContext).idents } + val identsHere = channel is ScheduledChannel && clockOffsetMs == 0L && channel.number != DRIVE_IN_NUMBER && next.startMs == current.endMs && (identsOn || channel.number == TOTS_NUMBER) val phase = if (identsHere) identPhase(current.startMs, current.endMs, now, identMs) else IdentPhase.None val playerContext = androidx.compose.ui.platform.LocalContext.current var loaded by remember(channel) { mutableStateOf(null) } diff --git a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt index bce2d12..1fe6413 100644 --- a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt +++ b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt @@ -33,7 +33,7 @@ internal class SecureMediaDataSource(private val session: EmbySampleSession) : B next.instanceFollowRedirects = false next.connectTimeout = 10_000 next.readTimeout = 20_000 - next.setRequestProperty("User-Agent", "OrbitTV/0.80.0") + next.setRequestProperty("User-Agent", "OrbitTV/0.81.0") next.setRequestProperty("Accept-Encoding", "identity") session.mediaHeaders(address).forEach { (key, value) -> next.setRequestProperty(key, value) } if (dataSpec.position != 0L || dataSpec.length != C.LENGTH_UNSET.toLong()) { 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 59c5b17..62de883 100644 --- a/android/app/src/main/java/tv/orbit/classic/Settings.kt +++ b/android/app/src/main/java/tv/orbit/classic/Settings.kt @@ -37,6 +37,15 @@ internal class OrbitSettings(context: Context) { var subtitlesOn: Boolean get() = saved.getBoolean("subtitles_on", false) set(value) { saved.edit().putBoolean("subtitles_on", value).apply() } + var channelLogo: Boolean + get() = saved.getBoolean("channel_logo", true) + set(value) { saved.edit().putBoolean("channel_logo", value).apply() } + var idents: Boolean + get() = saved.getBoolean("idents", true) + set(value) { saved.edit().putBoolean("idents", value).apply() } + var identSound: Boolean + get() = saved.getBoolean("ident_sound", true) + set(value) { saved.edit().putBoolean("ident_sound", value).apply() } } internal enum class SettingsSection(val label: String, val icon: Int) { @@ -110,14 +119,39 @@ private fun ChannelsDetail(busy: Boolean, message: String?, isOn: (Int) -> Boole } @Composable -private fun ViewingDetail(modifier: Modifier) { +private fun ViewingDetail(totsOn: Boolean, windingDown: Boolean, onWindDown: (Boolean) -> Unit, modifier: Modifier) { val context = LocalContext.current val settings = remember { OrbitSettings(context) } var subtitlesOn by remember { mutableStateOf(settings.subtitlesOn) } + var channelLogo by remember { mutableStateOf(settings.channelLogo) } + var idents by remember { mutableStateOf(settings.idents) } + var identSound by remember { mutableStateOf(settings.identSound) } + var confirming by remember { mutableStateOf(false) } Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { SettingLine("Subtitles", "Turn on English subtitles whenever you tune in, if the programme has them. You can still change them while watching, by pressing right.") { OrbitButton(if (subtitlesOn) "On" else "Off", Modifier.width(64.dp), selected = subtitlesOn, onClick = { subtitlesOn = !subtitlesOn; settings.subtitlesOn = subtitlesOn }) } + SettingLine("Channel logo", "Show the channel's small logo in the corner during programmes.") { + OrbitButton(if (channelLogo) "On" else "Off", Modifier.width(64.dp), selected = channelLogo, onClick = { channelLogo = !channelLogo; settings.channelLogo = channelLogo }) + } + SettingLine("Idents", "Play each channel's ident between programmes. When off, Tots shows a still picture instead.") { + OrbitButton(if (idents) "On" else "Off", Modifier.width(64.dp), selected = idents, onClick = { idents = !idents; settings.idents = idents }) + } + SettingLine("Ident sound", "Let Tots play its jingle, at most once every 30 minutes.") { + OrbitButton(if (identSound) "On" else "Off", Modifier.width(64.dp), selected = identSound, onClick = { identSound = !identSound; settings.identSound = identSound }) + } + if (totsOn) { + if (confirming) SettingLine(if (windingDown) "Go back to Tots' usual schedule now?" else "Start Wind Down on Tots?", if (windingDown) "The programme on Tots changes straight away." else "Calm shows begin when the current programme ends, and run until morning unless you stop them.") { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + val keep = remember { FocusRequester() } + LaunchedEffect(Unit) { keep.requestFocus() } + OrbitButton("Not now", Modifier.focusRequester(keep), onClick = { confirming = false }) + OrbitButton("Yes", onClick = { confirming = false; onWindDown(!windingDown) }) + } + } else SettingLine("Wind Down Now", "Calm shows on Tots at any time of day. Tots' usual schedule stays saved.") { + OrbitButton(if (windingDown) "On" else "Off", Modifier.width(64.dp), selected = windingDown, onClick = { confirming = true }) + } + } } } @@ -147,7 +181,8 @@ private fun AccountDetail(server: String, titles: Long?, onSignOut: () -> Unit, /** Everything behind the gear: a rail of sections on the left, and the chosen one beside it. */ @Composable internal fun SettingsScreen(section: SettingsSection, onSection: (SettingsSection) -> Unit, modifier: Modifier, busy: Boolean, message: String?, server: String, titles: Long?, - isOn: (Int) -> Boolean, onSwitch: (ChannelProgramming, Boolean) -> Unit, onSuggestions: (ChannelProgramming) -> Unit, onSignOut: () -> Unit) { + isOn: (Int) -> Boolean, onSwitch: (ChannelProgramming, Boolean) -> Unit, onSuggestions: (ChannelProgramming) -> Unit, onSignOut: () -> Unit, + windingDown: Boolean, onWindDown: (Boolean) -> Unit) { val firstFocus = remember { FocusRequester() } LaunchedEffect(Unit) { firstFocus.requestFocus() } Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -161,7 +196,7 @@ internal fun SettingsScreen(section: SettingsSection, onSection: (SettingsSectio val detail = Modifier.weight(1f).fillMaxHeight() when (section) { SettingsSection.Channels -> ChannelsDetail(busy, message, isOn, onSwitch, onSuggestions, detail) - SettingsSection.Viewing -> ViewingDetail(detail) + SettingsSection.Viewing -> ViewingDetail(isOn(TOTS_NUMBER), windingDown, onWindDown, detail) SettingsSection.Account -> AccountDetail(server, titles, onSignOut, detail) } } diff --git a/android/app/src/main/java/tv/orbit/classic/Tots.kt b/android/app/src/main/java/tv/orbit/classic/Tots.kt new file mode 100644 index 0000000..2b39120 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/Tots.kt @@ -0,0 +1,49 @@ +package tv.orbit.classic + +internal const val TOTS_NUMBER = 614 +/** Where Wind Down Now keeps its plan. Never a channel of its own, so the guide and settings don't see it. */ +internal const val TOTS_WIND_DOWN_FILE = 6140 +private const val TOTS_JINGLE_GAP_MS = 30 * 60_000L +private const val TOTS_DAY_STARTS = 6 + +/** Every title here is hand-approved, and spelled as the library spells it. */ +private val TotsMorning = listOf("Teletubbies", "Clifford the Big Red Dog", "Balamory (2026)", "Bluey (2018)", "Numberblocks", "Andy's Wild Adventures", "Andy's Dinosaur Adventures", "Andy's Baby Animals", "Something Special") +private val TotsPlaytime = listOf("Peppa Pig", "Ben and Holly's Little Kingdom", "Paw Patrol", "Fireman Sam", "Digby Dragon", "Floogals", "Bob the Builder", "Peter Rabbit (2013)", "Shimmer and Shine", + "Blaze and the Monster Machines", "Nella the Princess Knight", "Roary the Racing Car", "The Adventures of Paddington", "Rusty Rivets", "Noddy's Toyland Adventures", "Thomas & Friends: All Engines Go!", + "Bananas In Pyjamas", "Angelina Ballerina", "Olivia", "Mr Men", "Hey Duggee", "Octonauts", "Go Jetters") +internal val TotsWindDown = listOf("CBeebies Bedtime Stories", "In The Night Garden", "Clangers", "BeddyByes") + +internal val TotsShows get() = TotsMorning + TotsPlaytime + TotsWindDown +internal val TotsDayParts get() = listOf(DayPart(6, 9, TotsMorning), DayPart(9, 17, TotsMorning + TotsPlaytime), DayPart(17, 21, TotsWindDown), DayPart(21, 6, TotsWindDown)) + +internal enum class TotsMood(val video: Int, val still: Int, val lengthMs: Long, val fadeMs: Int, val sings: Boolean) { + Morning(R.raw.ident_614_morning, R.drawable.orbit_tots_still_morning, 5_000, 400, true), + Playtime(R.raw.ident_614_morning, R.drawable.orbit_tots_still_morning, 5_000, 400, false), + WindDown(R.raw.ident_614_evening, R.drawable.orbit_tots_still_evening, 6_000, 800, true), + Night(R.raw.ident_614_evening, R.drawable.orbit_tots_still_evening, 6_000, 800, false); + val evening get() = this == WindDown || this == Night +} + +internal fun totsMood(atMs: Long, zone: java.time.ZoneId = java.time.ZoneId.systemDefault()) = when (java.time.Instant.ofEpochMilli(atMs).atZone(zone).hour) { + in 6 until 9 -> TotsMood.Morning + in 9 until 17 -> TotsMood.Playtime + in 17 until 21 -> TotsMood.WindDown + else -> TotsMood.Night +} + +/** Wind Down Now always hands back by the start of the next morning. */ +internal fun totsWindDownEnd(fromMs: Long, zone: java.time.ZoneId = java.time.ZoneId.systemDefault()): Long { + val from = java.time.Instant.ofEpochMilli(fromMs).atZone(zone) + val today = from.toLocalDate().atTime(TOTS_DAY_STARTS, 0).atZone(zone) + return (if (from.isBefore(today)) today else today.plusDays(1)).toInstant().toEpochMilli() +} + +/** A short episode can mean an ident every few minutes, so most stay silent. */ +internal object TotsJingle { + @Volatile private var lastMs = Long.MIN_VALUE / 2 + fun take(mood: TotsMood, nowMs: Long): Boolean { + if (!mood.sings || nowMs - lastMs < TOTS_JINGLE_GAP_MS) return false + lastMs = nowMs + return true + } +} diff --git a/android/app/src/main/res/drawable-nodpi/orbit_tots_bug.webp b/android/app/src/main/res/drawable-nodpi/orbit_tots_bug.webp new file mode 100644 index 0000000..dd75938 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_tots_bug.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_tots_epg.webp b/android/app/src/main/res/drawable-nodpi/orbit_tots_epg.webp new file mode 100644 index 0000000..c94875e Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_tots_epg.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_tots_sleepy.webp b/android/app/src/main/res/drawable-nodpi/orbit_tots_sleepy.webp new file mode 100644 index 0000000..b869305 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_tots_sleepy.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_tots_still_evening.webp b/android/app/src/main/res/drawable-nodpi/orbit_tots_still_evening.webp new file mode 100644 index 0000000..d69cfe5 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_tots_still_evening.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_tots_still_morning.webp b/android/app/src/main/res/drawable-nodpi/orbit_tots_still_morning.webp new file mode 100644 index 0000000..5c46871 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_tots_still_morning.webp differ diff --git a/android/app/src/main/res/raw/ident_614_evening.mp4 b/android/app/src/main/res/raw/ident_614_evening.mp4 new file mode 100644 index 0000000..4c45341 Binary files /dev/null and b/android/app/src/main/res/raw/ident_614_evening.mp4 differ diff --git a/android/app/src/main/res/raw/ident_614_morning.mp4 b/android/app/src/main/res/raw/ident_614_morning.mp4 new file mode 100644 index 0000000..b68539e Binary files /dev/null and b/android/app/src/main/res/raw/ident_614_morning.mp4 differ diff --git a/android/app/src/test/java/tv/orbit/classic/ChannelPresetsTest.kt b/android/app/src/test/java/tv/orbit/classic/ChannelPresetsTest.kt index 1c8f35b..5a7bad4 100644 --- a/android/app/src/test/java/tv/orbit/classic/ChannelPresetsTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/ChannelPresetsTest.kt @@ -163,7 +163,7 @@ class ChannelPresetsTest { } listOf(612, 613, 306, 303, 304, 302, 307, 301).forEach { assertNotNull(SmartChannels.first { channel -> channel.number == it }.filmRestMs) } assertEquals(ChannelCategory.Sport, ChannelBrands.forNumber(401)!!.category) - assertEquals(29, SmartChannels.size) + assertEquals(30, SmartChannels.size) } @Test fun ringXKeepsItsWeeknightsAndFallsBackToTheVault() { @@ -194,7 +194,7 @@ class ChannelPresetsTest { @Test fun everySmartChannelIsBrandedAndNumberedAfterTheMove() { SmartChannels.forEach { assertNotNull("${it.name} has no brand at ${it.number}", ChannelBrands.forNumber(it.number)) } - assertEquals(OldChannelNumbers.values.toSet() - DRIVE_IN_NUMBER + 101, SmartChannels.map { it.number }.toSet()) + assertEquals(OldChannelNumbers.values.toSet() - DRIVE_IN_NUMBER + 101 + TOTS_NUMBER, SmartChannels.map { it.number }.toSet()) } @Test fun channelsSitInTheirBlocks() { diff --git a/android/app/src/test/java/tv/orbit/classic/TotsTest.kt b/android/app/src/test/java/tv/orbit/classic/TotsTest.kt new file mode 100644 index 0000000..9276b73 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/TotsTest.kt @@ -0,0 +1,93 @@ +package tv.orbit.classic + +import org.junit.Assert.* +import org.junit.Test +import java.time.ZoneId +import java.time.ZonedDateTime + +class TotsTest { + private val zone = ZoneId.of("Europe/London") + private fun at(hour: Int, day: Int = 3) = ZonedDateTime.of(2026, 11, day, hour, 0, 0, 0, zone).toInstant().toEpochMilli() + private fun show(title: String, count: Int) = PoolSeries(title, title, (1..count).map { PoolEpisode("$title$it", "s", EpisodeMark(1, it), "E$it", 10 * 60_000L) }) + + @Test fun moodFollowsLocalHours() { + assertEquals(TotsMood.Night, totsMood(at(5), zone)) + assertEquals(TotsMood.Morning, totsMood(at(6), zone)) + assertEquals(TotsMood.Playtime, totsMood(at(9), zone)) + assertEquals(TotsMood.WindDown, totsMood(at(17), zone)) + assertEquals(TotsMood.Night, totsMood(at(21), zone)) + } + + @Test fun moodMovesWithTheClocks() { + // 06:00 British Summer Time on the last Saturday of October, an hour earlier by the universal clock than in winter. + val summer = ZonedDateTime.of(2026, 10, 24, 6, 0, 0, 0, zone).toInstant().toEpochMilli() + assertEquals(TotsMood.Morning, totsMood(summer, zone)) + } + + @Test fun onlyTheTwoMoodsSing() { + assertFalse(TotsJingle.take(TotsMood.Playtime, 1_000_000_000L)) + assertFalse(TotsJingle.take(TotsMood.Night, 1_000_000_000L)) + } + + @Test fun jingleWaitsThirtyMinutes() { + val start = 2_000_000_000_000L + assertTrue(TotsJingle.take(TotsMood.Morning, start)) + assertFalse(TotsJingle.take(TotsMood.Morning, start + 29 * 60_000L)) + assertTrue(TotsJingle.take(TotsMood.WindDown, start + 30 * 60_000L)) + } + + @Test fun identFollowsTheNextProgramme() { + assertEquals(TotsMood.Morning.video, identVideo(TOTS_NUMBER, at(7))) + assertEquals(5_000L, identLength(TOTS_NUMBER, at(7))) + assertEquals(6_000L, identLength(TOTS_NUMBER, at(18))) + } + + @Test fun everyEpisodePlaysOnceBeforeAnyRepeat() { + val pool = listOf(show("a", 12), show("b", 12)) + val plan = extendSchedule(pool, ScheduleCursor(), at(10), at(10) + 24 * 10 * 60_000L, finishTogether = true, zone = zone, shuffle = true) + assertEquals(24, plan.entries.size) + assertEquals(24, plan.entries.map { it.media.item.id }.distinct().size) + } + + @Test fun shuffleIsMixedAndRepeatable() { + val series = show("a", 40) + assertEquals(shuffledEpisodes(series), shuffledEpisodes(series)) + assertNotEquals(series.episodes, shuffledEpisodes(series)) + } + + @Test fun shuffleCarriesOnFromItsPlace() { + val pool = listOf(show("a", 6)) + val first = extendSchedule(pool, ScheduleCursor(), at(10), at(10) + 30 * 60_000L, zone = zone, shuffle = true) + val rest = extendSchedule(pool, first.tail, first.endMs, first.endMs + 30 * 60_000L, zone = zone, shuffle = true) + assertEquals(6, (first.entries + rest.entries).map { it.media.item.id }.distinct().size) + } + + @Test fun otherChannelsStayInSeasonOrder() { + val plan = extendSchedule(listOf(show("a", 5)), ScheduleCursor(), at(10), at(10) + 50 * 60_000L, zone = zone) + assertEquals((1..5).toList(), plan.entries.map { it.mark.episode }) + } + + @Test fun windDownEndsAtTheNextMorning() { + assertEquals(at(6, 4), totsWindDownEnd(at(10), zone)) + assertEquals(at(6, 4), totsWindDownEnd(at(20), zone)) + assertEquals(at(6), totsWindDownEnd(at(2), zone)) + } + + @Test fun eveningOnlyOffersCalmShows() { + val calm = TotsWindDown.map(::seriesKey).toSet() + TotsDayParts.filter { it.covers(18) || it.covers(23) }.forEach { part -> assertEquals(calm, part.titles.map(::seriesKey).toSet()) } + } + + @Test fun stepInCoversThePlanThenHandsBack() { + val pool = listOf(show("a", 50)) + val main = extendSchedule(pool, ScheduleCursor(), at(10), at(14), zone = zone) + val channel = ScheduledChannel(SampleChannel(main.entries.take(2).map { it.media }, "t", TOTS_NUMBER), main) + val calm = listOf(show("calm", 5)) + channel.stepIn = extendSchedule(calm, ScheduleCursor(), at(11), at(11) + 50 * 60_000L, zone = zone) + assertEquals("calm1", channel.at(at(11) + 1).media.item.id) + assertTrue(channel.at(at(13)).media.item.id.startsWith("a")) + assertEquals(main.entries.size, channel.plan.entries.size) + channel.stepIn = null + assertTrue(channel.at(at(11) + 1).media.item.id.startsWith("a")) + } +} diff --git a/design/channels/build-idents.sh b/design/channels/build-idents.sh index 546f674..1a34436 100755 --- a/design/channels/build-idents.sh +++ b/design/channels/build-idents.sh @@ -34,3 +34,6 @@ done <<'LIST' 612 world/wonder/wonder_3d.mp4 613 world/orbit-pixelz/orbit-pixelz_3d.mp4 LIST +# Tots keeps its jingles, and has a film for morning and evening. Built by orbit-tots/build-idents.py. +cp "$D/tots/tots_morning.mp4" "$OUT/ident_614_morning.mp4" +cp "$D/tots/tots_evening.mp4" "$OUT/ident_614_evening.mp4" diff --git a/design/channels/build-tots.py b/design/channels/build-tots.py new file mode 100644 index 0000000..814ff42 --- /dev/null +++ b/design/channels/build-tots.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Vector logo for Orbit Tots: chunky hand-drawn letters, with the O a smiling little planet wearing the network's ring. +Letters are drawn as round strokes, not set in a font, so the shapes are our own.""" +import importlib.util, math, subprocess, sys +from pathlib import Path +HERE = Path(__file__).parent +spec = importlib.util.spec_from_file_location('brands', HERE / 'build-more-brands.py') +OUTLINE, CORAL, SUN, SKY, LEAF, RING = '#0B1730', '#FF6B5E', '#FFB52E', '#45C4FF', '#7BDC6B', '#F4F7FF' +WIDTH = 25 + +def stroke(path, colour, width=WIDTH): + return f'' + +def letter(paths, colour): + # A navy edge first, so it reads over bright programme artwork, then the colour. + return ''.join(stroke(p, OUTLINE, WIDTH + 9) for p in paths) + ''.join(stroke(p, colour) for p in paths) + +def tee(x, y, colour, tilt): + return f'' + letter([f'M{x - 25} {y + 2} Q{x} {y - 3} {x + 25} {y + 1}', f'M{x} {y + 2} Q{x + 2} {y + 28} {x - 1} {y + 56}'], colour) + '' + +def star(x, y, r, colour): + points = ' '.join(f'{x + (r if i % 2 == 0 else r * .46) * math.sin(i * math.pi / 5):.1f},{y - (r if i % 2 == 0 else r * .46) * math.cos(i * math.pi / 5):.1f}' for i in range(10)) + return f'' + +def nightcap(x, y, r, sway=0.0, cloth='#A99BE8', brim='#EEE9FF', tip='#FFE08A'): + # Sits above the eyes, so it is the same face. Sway moves only the drooping tip. + p = lambda u, v: f'{x + u * r:.1f} {y + v * r:.1f}' + tx, ty = 1.2 + sway * .16, -.86 + sway * .05 + return (f'' + + stroke(f'M{p(-.76, -.62)} Q{p(0, -.92)} {p(.76, -.62)}', OUTLINE, r * .34) + stroke(f'M{p(-.76, -.62)} Q{p(0, -.92)} {p(.76, -.62)}', brim, r * .2) + + star(x + tx * r, y + ty * r, r * .2, tip)) + +def planet(x, y, r, colour, ring_colour=RING, eyes=1.0): + ring = lambda arc: f'' + # The ring sits low, under the smile. + rx, ry, low = r * 1.42, r * .22, y + r * .38 + back = f'M{x - rx} {low} A{rx} {ry} 0 0 1 {x + rx} {low}' + front = f'M{x - rx} {low} A{rx} {ry} 0 0 0 {x + rx} {low}' + # Eyes close to a soft line for the sleepy blink. + lid = max(.025, .17 * eyes) + face = (f'' + f'' + f'') + return (f'{ring(back)}' + f'{face}' + f'{ring(front)}') + +def ess(x, y, colour): + return letter([f'M{x + 24} {y + 5} C{x + 10} {y - 8} {x - 24} {y - 6} {x - 22} {y + 13} C{x - 20} {y + 31} {x + 24} {y + 25} {x + 24} {y + 44} C{x + 24} {y + 63} {x - 14} {y + 64} {x - 26} {y + 48}'], colour) + +def tots(dx, dy, scale): + return (f'' + tee(40, 22, CORAL, -5) + planet(124, 44, 36, SUN) + tee(214, 18, SKY, 4) + ess(292, 22, LEAF) + '') + +if __name__ == '__main__': + sys.dont_write_bytecode = True + source = spec.loader.get_source('brands').split("arrow = lambda")[0] + brands = {'__file__': str(HERE / 'build-more-brands.py')} + exec(compile(source, 'build-more-brands.py', 'exec'), brands) + brands['both']('orbit_tots', + brands['mark'](SUN) + brands['ORBIT'] + tots(134, 58, 1.0), + # The playback bug is the wordmark and planet alone. + tots(10, 2, 1.1)) + # The sleepy planet, for the evening tuning screen. + src = HERE / 'orbit-tots/orbit_tots_sleepy.svg' + src.write_text(brands['svg'](256, 256, planet(120, 150, 72, SUN) + nightcap(120, 150, 72))) + png = src.with_suffix('.png') + subprocess.run(['vectorcraft-cli', 'convert', str(src), str(png)], check=True, stdout=subprocess.DEVNULL) + brands['Image'].open(png).convert('RGBA').save(brands['RES'] / 'orbit_tots_sleepy.webp', quality=92) + png.unlink() diff --git a/design/channels/orbit-tots/audio/jingle_a_morning.wav b/design/channels/orbit-tots/audio/jingle_a_morning.wav new file mode 100644 index 0000000..89f44fe Binary files /dev/null and b/design/channels/orbit-tots/audio/jingle_a_morning.wav differ diff --git a/design/channels/orbit-tots/audio/jingle_b_evening.wav b/design/channels/orbit-tots/audio/jingle_b_evening.wav new file mode 100644 index 0000000..efe1597 Binary files /dev/null and b/design/channels/orbit-tots/audio/jingle_b_evening.wav differ diff --git a/design/channels/orbit-tots/build-idents.py b/design/channels/orbit-tots/build-idents.py new file mode 100644 index 0000000..efd1653 --- /dev/null +++ b/design/channels/orbit-tots/build-idents.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""The two Orbit Tots films: Hello, TOTS! and Sleepy TOTS. Soft flat illustration drawn from the logo's own shapes, one SVG a frame. + build-idents.py [frame numbers for stills] +""" +import concurrent.futures, importlib.util, math, os, random, subprocess, sys +from pathlib import Path +HERE = Path(__file__).parent +spec = importlib.util.spec_from_file_location('tots', HERE.parent / 'build-tots.py') +tots = importlib.util.module_from_spec(spec); spec.loader.exec_module(tots) +source = (HERE.parent / 'build-more-brands.py').read_text().split('arrow = lambda')[0] +brands = {'__file__': str(HERE.parent / 'build-more-brands.py')} +exec(compile(source, 'build-more-brands.py', 'exec'), brands) +FPS, W, H = 30, 1280, 720 +# Where the guide logo lands, drawn large. All positions below are in the logo's own 512 x 160 units inside this. +ZOOM, LEFT, TOP = 2.2, 77, 184 +WORD_X, WORD_Y = 134, 58 +FINAL = {'t1': (40, 22), 'o': (124, 44), 't2': (214, 18), 's': (292, 22)} + +def clamp(x): return max(0.0, min(1.0, x)) +def ease(x): x = clamp(x); return x * x * (3 - 2 * x) +def settle(x): + # Ease out with one soft overshoot of about 6%. + x = clamp(x); return 1 - (1 - x) ** 3 * (1 - 1.7 * x) +def between(t, a, b): return (t - a) / (b - a) +def screen(u, v): return LEFT + ZOOM * (WORD_X + u), TOP + ZOOM * (WORD_Y + v) +def bezier(points, u): + while len(points) > 1: + points = [(a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u) for a, b in zip(points, points[1:])] + return points[0] + +def letter(body, name, rise, scale, opacity): + """A letter drawn at its final place, moved by [rise] screen pixels and scaled about its own centre.""" + u, v = FINAL[name] + x, y = screen(u + (0 if name == 's' else 0), v + 28) + return (f'{body}') + +def mark_layer(ring_colour, reveal, opacity, cloth='url(#silver)', text='#0B1730'): + # The crescent gains a navy edge so it holds on a bright sky. Its ring draws on as a swoosh. + n = 40 + # The network's swoosh, from build-more-brands, as points so it can be drawn on a little at a time. + a = [(129, 369), (102, 399), (111, 425), (161, 428)]; b = [(161, 428), (267, 432), (431, 340), (445, 289)]; c = [(445, 289), (455, 250), (405, 250), (369, 261)] + points = [bezier(a, i / n) for i in range(n)] + [bezier(b, i / n) for i in range(n)] + [bezier(c, i / n) for i in range(n + 1)] + shown = points[:max(2, int(len(points) * clamp(reveal)))] + line = ' '.join(f'{x:.1f},{y:.1f}' for x, y in shown) + swoosh = (f'' + f'') + crescent = f'' + word = brands['text'](134, 50, 'ORBIT', 18, text, 'Barlow', '600', 'letter-spacing="5"') + return (f'{word}{crescent}' + f'{swoosh if reveal > 0 else ""}') + +class Scatter: + """Small shapes that drift slowly and never flash.""" + def __init__(self, seed, count, colours, y_range=(40, 620), speed=10): + rng = random.Random(seed) + self.items = [(rng.uniform(0, W), rng.uniform(*y_range), rng.uniform(9, 20), rng.choice(colours), rng.uniform(.4, 1) * speed, rng.uniform(0, math.tau), rng.choice(('star', 'dot'))) for _ in range(count)] + def draw(self, t, opacity=1.0, outline=False): + out = '' + for x, y, r, colour, speed, phase, kind in self.items: + px, py = (x + speed * t) % W, y + 7 * math.sin(t * .8 + phase) + spin = 12 * math.sin(t * .6 + phase) + shape = (f'' + star_shape(px, py, r, colour) + '') if kind == 'star' else f'' + out += f'{shape}' + return out + +def star_shape(x, y, r, colour): + points = ' '.join(f'{x + (r if i % 2 == 0 else r * .5) * math.sin(i * math.pi / 5):.1f},{y - (r if i % 2 == 0 else r * .5) * math.cos(i * math.pi / 5):.1f}' for i in range(10)) + return f'' + +def page(body, defs): + return f'{brands["SILVER"]}{defs}{body}' + +MORNING_BITS = Scatter(4, 16, ('#FFF4C2', '#FFFFFF', '#FFC2D1', '#CDBBF5', '#FFE08A')) +def morning(frame): + t = (frame - 1) / FPS + defs = '' + body = f'' + # The sunburst, turning slowly behind everything. + cx, cy = W / 2, 400 + for i in range(12): + a = i * math.tau / 12 + t * .12 + p = lambda d: f'{cx + 1200 * math.cos(a + d):.1f},{cy + 1200 * math.sin(a + d):.1f}' + body += f'' + body += f'' + # Soft hills along the foot. + body += f'' + body += MORNING_BITS.draw(t) + # Mark first, so the letters sit over its ring. + body += mark_layer(tots.SUN, between(t, 2.6, 3.4), ease(between(t, 2.5, 3.1))) + for name, colour_body, start in (('t1', tots.tee(*FINAL['t1'], tots.CORAL, -5), 1.05), ('t2', tots.tee(*FINAL['t2'], tots.SKY, 4), 1.4), ('s', tots.ess(*FINAL['s'], tots.LEAF), 1.75)): + u = between(t, start, start + .65) + if u > 0: + body += letter(colour_body, name, 130 * (1 - settle(u)), .6 + .4 * settle(u), ease(u * 3.5)) + # The planet floats in, one small bounce, then a curved path into the O. + fx, fy = screen(*FINAL['o']) + if t < 1.0: + x, y = bezier([(-130, 560), (120, 300), (330, 320)], ease(t / 1.0)); size = 1.5 + elif t < 1.5: + x, y = 330, 320 - 62 * math.sin(math.pi * between(t, 1.0, 1.5)); size = 1.5 + else: + u = ease(between(t, 1.5, 2.6)) + x, y = bezier([(330, 320), (470, 90), (820, 110), (fx, fy)], u); size = 1.5 - .5 * u + tilt = 10 * math.sin(t * 2.4) * (1 - ease(between(t, 2.2, 3.0))) + body += f'' + tots.planet(*FINAL['o'], 36, tots.SUN) + '' + return page(body, defs) + +EVENING_STARS = Scatter(9, 34, ('#FFE9A8', '#EDE6FF', '#FFD9E4'), (20, 500), speed=3) +SOFT = dict(t1='#E58B85', t2='#83B9E6', s='#97CB98', o='#F0C468', ring='#E6E1F4') +def evening(frame): + t = (frame - 1) / FPS + defs = '' + dim = 1 - ease(between(t, 0, 1.5)) + body = f'' + # A low pink glow where the sun went down, and quiet hills. + body += f'' + body += EVENING_STARS.draw(t, .85 * ease(between(t, .5, 2.4))) + # The moon-pale swoosh and crescent come last, with almost no movement. + body += mark_layer(SOFT['o'], between(t, 3.6, 4.6), ease(between(t, 3.6, 4.6)), cloth='#DAD6EC', text='#E6E1F4') + for name, colour_body, start in (('t1', tots.tee(*FINAL['t1'], SOFT['t1'], -5), 3.0), ('t2', tots.tee(*FINAL['t2'], SOFT['t2'], 4), 3.25), ('s', tots.ess(*FINAL['s'], SOFT['s']), 3.5)): + u = between(t, start, start + .9) + if u > 0: + body += letter(colour_body, name, 16 * (1 - ease(u)), 1, ease(u)) + # Floating, never bouncing. The whole way down takes most of the film. + fx, fy = screen(*FINAL['o']) + u = ease(between(t, .2, 3.9)) + x, y = bezier([(-140, 150), (300, 120), (560, 210), (fx, fy)], u) + size = 1.45 - .45 * ease(between(t, 2.4, 3.9)) + bob = 5 * math.sin(t * 1.3) * (1 - ease(between(t, 3.4, 4.4))) + # One slow blink at 2 seconds, then eyes a little heavy. + close = ease(between(t, 1.9, 2.3)) * (1 - ease(between(t, 2.55, 3.0))) + eyes = (1 - close) * (1 - .25 * ease(between(t, 3.0, 4.2))) + sway = 1.3 * math.sin(t * 1.7) * math.exp(-max(0, t - 2.4) * .55) + tilt = 7 * math.sin(t * .9) * (1 - ease(between(t, 3.0, 4.2))) + body += (f'' + + tots.planet(*FINAL['o'], 36, SOFT['o'], ring_colour=SOFT['ring'], eyes=eyes) + tots.nightcap(*FINAL['o'], 36, sway) + '') + # Up from a dusky sky, and gently back down into it at the end. + shade = max(dim, ease(between(t, 5.45, 6.0))) + if shade > 0: body += f'' + return page(body, defs) + +def render(job): + draw, frame, folder = job + src = folder / f'f_{frame:04d}.svg' + src.write_text(draw(frame)) + env = dict(os.environ) + if env.get('ORBIT_FONT_HOME'): env['XDG_DATA_HOME'] = env['ORBIT_FONT_HOME'] + subprocess.run(['vectorcraft-cli', 'convert', str(src), str(folder / f'f_{frame:04d}.png')], check=True, stdout=subprocess.DEVNULL, env=env) + src.unlink() + +if __name__ == '__main__': + which, folder = sys.argv[1], Path(sys.argv[2]); folder.mkdir(parents=True, exist_ok=True) + draw, count = (morning, 150) if which == 'morning' else (evening, 180) + frames = [int(f) for f in sys.argv[3:]] or list(range(1, count + 1)) + with concurrent.futures.ThreadPoolExecutor(6) as pool: + list(pool.map(render, [(draw, frame, folder) for frame in frames])) diff --git a/design/channels/orbit-tots/build-jingles.py b/design/channels/orbit-tots/build-jingles.py new file mode 100644 index 0000000..a2d667a --- /dev/null +++ b/design/channels/orbit-tots/build-jingles.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""The two Orbit Tots jingles, written and synthesised here, so there's no licence to clear. One tune, two arrangements. +Morning lands as the planet settles at 2.5 s, evening at 3.3 s, to match the storyboards. Loudness is set afterwards by ffmpeg. + build-jingles.py +""" +import math, random, struct, sys, wave +RATE = 44100 +# The tune, as semitones above its first note: rising, then landing on one long note. +TUNE = (0, 2, 5, 2, 5, 9) + +def freq(note): return 440 * 2 ** ((note - 69) / 12) +def smooth(x): return 0.0 if x <= 0 else 1.0 if x >= 1 else x * x * (3 - 2 * x) + +class Mix: + def __init__(self, seconds): + self.size = int(RATE * seconds) + self.left, self.right = [0.0] * self.size, [0.0] * self.size + def add(self, start, samples, loud=1.0, pan=0.0): + first = int(start * RATE) + for i, value in enumerate(samples): + if first + i >= self.size: break + self.left[first + i] += value * loud * (1 - pan) * .5 + self.right[first + i] += value * loud * (1 + pan) * .5 + +def tone(f, seconds, partials, attack=.003): + """Partials are (ratio, level, decay per second).""" + out = [] + for i in range(int(seconds * RATE)): + t = i / RATE + out.append(min(1, t / attack) * sum(level * math.sin(math.tau * f * ratio * t) * math.exp(-t * decay) for ratio, level, decay in partials)) + return out + +def marimba(note, seconds=1.4): return tone(freq(note), seconds, ((1, 1, 5.5), (4, .22, 16), (9.8, .05, 40))) +def bell(note, seconds=2.6): return tone(freq(note), seconds, ((1, 1, 2.2), (2.76, .35, 3.6), (5.4, .12, 7))) +def music_box(note, seconds=3.0): return tone(freq(note), seconds, ((1, 1, 1.5), (3, .18, 3), (6.3, .1, 11)), attack=.002) +def piano(note, seconds=3.5): return tone(freq(note), seconds, ((1, 1, 1.0), (2, .32, 1.7), (3, .08, 2.6)), attack=.03) + +def pluck(note, seconds=1.6, seed=1): + # A plucked string: a burst of noise going round a short loop, losing its brightness as it goes. + rng = random.Random(seed) + period = max(2, int(RATE / freq(note))) + line = [rng.uniform(-1, 1) for _ in range(period)] + for _ in range(3): + line = [(line[i] + line[i - 1]) * .5 for i in range(period)] + out = [] + for i in range(int(seconds * RATE)): + at = i % period + value = line[at] + line[at] = (value + line[at - 1]) * .5 * .9975 + out.append(value) + return out + +def pad(notes, seconds, rise): + out = [0.0] * int(seconds * RATE) + for note in notes: + for detune in (-.05, .05): + f = freq(note + detune) + for i in range(len(out)): + t = i / RATE + out[i] += math.sin(math.tau * f * t) * smooth(t / rise) * (1 - smooth((t - seconds + rise) / rise)) * .25 + return out + +def room(mix, delays, feedback, wet): + for channel, times in ((mix.left, delays), (mix.right, tuple(d + 113 for d in delays))): + out = [0.0] * mix.size + for delay in times: + line = [0.0] * delay + for i in range(mix.size): + back = line[i % delay] + line[i % delay] = channel[i] + back * feedback + out[i] += back * wet + for i in range(mix.size): channel[i] += out[i] + +def save(path, mix, fade_in, fade_out): + peak = max(max(abs(a), abs(b)) for a, b in zip(mix.left, mix.right)) + with wave.open(path, 'wb') as out: + out.setnchannels(2); out.setsampwidth(2); out.setframerate(RATE) + frames = bytearray() + length = mix.size / RATE + for i in range(mix.size): + t = i / RATE + fade = smooth(t / fade_in) * (1 - smooth((t - length + fade_out) / fade_out)) + frames += struct.pack(' \ No newline at end of file diff --git a/design/channels/orbit-tots/orbit_tots_epg.svg b/design/channels/orbit-tots/orbit_tots_epg.svg new file mode 100644 index 0000000..0f42537 --- /dev/null +++ b/design/channels/orbit-tots/orbit_tots_epg.svg @@ -0,0 +1 @@ +ORBIT \ No newline at end of file diff --git a/design/channels/orbit-tots/orbit_tots_full_logo.png b/design/channels/orbit-tots/orbit_tots_full_logo.png new file mode 100644 index 0000000..37367d0 Binary files /dev/null and b/design/channels/orbit-tots/orbit_tots_full_logo.png differ diff --git a/design/channels/orbit-tots/orbit_tots_sleepy.svg b/design/channels/orbit-tots/orbit_tots_sleepy.svg new file mode 100644 index 0000000..679b17d --- /dev/null +++ b/design/channels/orbit-tots/orbit_tots_sleepy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/refinement-notes.md b/docs/refinement-notes.md index 609999d..06e679a 100644 --- a/docs/refinement-notes.md +++ b/docs/refinement-notes.md @@ -63,3 +63,10 @@ Tickets, reminders and the 20:00 start all worked on the TV. The film took 7.9 s ## Drive-In "next" label The player's NEXT line asks the channel for the following programme, and between bookings a channel fills from its loop. On the Drive-In that named a film that would never play (Toy Story 2 instead of 23:00 Misery). The film itself ends and returns to the lot. Show the next booking instead. + +## Tots, 3 November 2026 + +- A channel with very short episodes means an ident every few minutes, so a jingle needs a minimum gap. When other channels get sound, they'll want the same rule. +- Exact title matching needs the library's own spelling, years included, such as Bluey (2018). A later tool that checks a channel against a new user's library shouldn't depend on this. +- Idents, logo and sound had no settings until Tots needed them. Every new user will want these from day one. +- Child safety isn't a Tots feature. A PIN and lock should exist before this app goes to anyone else with children.