0.81.0: Orbit Tots, with shuffled episodes, day-part idents and Wind Down Now
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m58s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m35s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-03 00:32:01 +00:00
co-authored by Claude Opus 5.5
parent ef41d04dec
commit a5ea55e1e2
34 changed files with 663 additions and 31 deletions
+34
View File
@@ -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.
+2 -2
View File
@@ -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 }
@@ -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),
@@ -19,7 +19,9 @@ internal data class ChannelProgramming(val number: Int, val name: String, val se
val dayParts: List<DayPart> = emptyList(),
val suggestionRules: List<SuggestionRule> = 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<PoolEpisode> = 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<PoolSeries>, cursor: ScheduleCursor,
/** Shows take turns, one episode each, back to back from [fromMs] until [untilMs] is covered. */
internal fun extendSchedule(pool: List<PoolSeries>, cursor: ScheduleCursor, fromMs: Long, untilMs: Long, filmRestMs: Long? = null, finishTogether: Boolean = false,
dayParts: List<DayPart> = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }): ChannelPlan {
dayParts: List<DayPart> = 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<ScheduleEntry>()
var last = cursor.lastSeriesId
var start = fromMs
val mixes = HashMap<String, List<PoolEpisode>>()
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<ScheduleEntry>, now
* With no usable plan it starts from now at the aired position.
*/
internal fun topUpPlan(existing: ChannelPlan?, aired: ScheduleCursor, pool: List<PoolSeries>?, nowMs: Long, filmRestMs: Long? = null, finishTogether: Boolean = false,
dayParts: List<DayPart> = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }): ChannelPlan? {
dayParts: List<DayPart> = 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
@@ -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 })
@@ -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)
@@ -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)
@@ -88,6 +88,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
var tickets by remember { mutableStateOf<Map<Long, Int>>(emptyMap()) }
var pendingDriveInWatch by remember { mutableStateOf(false) }
var reminder by remember { mutableStateOf<DriveInReminder?>(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) {
@@ -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<String?>(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,
@@ -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() } }
@@ -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<LoadedNext?>(null) }
@@ -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()) {
@@ -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)
}
}
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.
Binary file not shown.
@@ -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() {
@@ -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"))
}
}
+3
View File
@@ -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"
+69
View File
@@ -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'<path d="{path}" fill="none" stroke="{colour}" stroke-width="{width}" stroke-linecap="round" stroke-linejoin="round"/>'
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'<g transform="rotate({tilt} {x} {y + 30})">' + 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) + '</g>'
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'<polygon points="{points}" fill="{colour}" stroke="{OUTLINE}" stroke-width="{r * .32:.1f}" stroke-linejoin="round" paint-order="stroke"/>'
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'<path d="M{p(-.8, -.6)} Q{p(-.42, -1.72)} {p(.32, -1.62)} Q{p(.92, -1.52)} {p(tx, ty)} Q{p(.72, -1.2)} {p(.8, -.6)} Z" fill="{cloth}" stroke="{OUTLINE}" stroke-width="5" stroke-linejoin="round"/>'
+ 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'<path d="{arc}" fill="none" stroke="{OUTLINE}" stroke-width="12" stroke-linecap="round"/><path d="{arc}" fill="none" stroke="{ring_colour}" stroke-width="6" stroke-linecap="round"/>'
# 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'<ellipse cx="{x - r * .34}" cy="{y - r * .42}" rx="{r * .12}" ry="{r * lid}" fill="{OUTLINE}"/>'
f'<ellipse cx="{x + r * .34}" cy="{y - r * .42}" rx="{r * .12}" ry="{r * lid}" fill="{OUTLINE}"/>'
f'<path d="M{x - r * .32} {y - r * .08} Q{x} {y + r * .32} {x + r * .32} {y - r * .08}" fill="none" stroke="{OUTLINE}" stroke-width="{r * .12}" stroke-linecap="round"/>')
return (f'<g transform="rotate(-12 {x} {low})">{ring(back)}</g>'
f'<circle cx="{x}" cy="{y}" r="{r}" fill="{colour}" stroke="{OUTLINE}" stroke-width="5"/>{face}'
f'<g transform="rotate(-12 {x} {low})">{ring(front)}</g>')
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'<g transform="translate({dx},{dy}) scale({scale})">' + tee(40, 22, CORAL, -5) + planet(124, 44, 36, SUN) + tee(214, 18, SKY, 4) + ess(292, 22, LEAF) + '</g>')
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()
+155
View File
@@ -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 <morning|evening> <out folder> [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'<g opacity="{opacity:.3f}" transform="translate({x:.2f},{y + rise:.2f}) scale({ZOOM * scale:.4f}) translate({-u:.2f},{-v - 28:.2f})">{body}</g>')
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'<polyline points="{line}" fill="none" stroke="{tots.OUTLINE}" stroke-width="28" stroke-linecap="round" stroke-linejoin="round"/>'
f'<polyline points="{line}" fill="none" stroke="{ring_colour}" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>')
crescent = f'<path d="{brands["CRESCENT"]}" fill="{cloth}" stroke="{tots.OUTLINE}" stroke-width="16" stroke-linejoin="round" paint-order="stroke"/>'
word = brands['text'](134, 50, 'ORBIT', 18, text, 'Barlow', '600', 'letter-spacing="5"')
return (f'<g transform="translate({LEFT},{TOP}) scale({ZOOM})"><g opacity="{opacity:.3f}">{word}<g transform="translate(-5,1) scale(.26)">{crescent}</g></g>'
f'<g transform="translate(-5,1) scale(.26)">{swoosh if reveal > 0 else ""}</g></g>')
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'<g transform="rotate({spin:.1f} {px:.1f} {py:.1f})">' + star_shape(px, py, r, colour) + '</g>') if kind == 'star' else f'<circle cx="{px:.1f}" cy="{py:.1f}" r="{r * .6:.1f}" fill="{colour}"/>'
out += f'<g opacity="{opacity:.3f}">{shape}</g>'
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'<polygon points="{points}" fill="{colour}" stroke="{colour}" stroke-width="{r * .35:.1f}" stroke-linejoin="round"/>'
def page(body, defs):
return f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}"><defs>{brands["SILVER"]}{defs}</defs>{body}</svg>'
MORNING_BITS = Scatter(4, 16, ('#FFF4C2', '#FFFFFF', '#FFC2D1', '#CDBBF5', '#FFE08A'))
def morning(frame):
t = (frame - 1) / FPS
defs = '<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#5DBDF5"/><stop offset=".7" stop-color="#A9E2FB"/><stop offset="1" stop-color="#D6F3FB"/></linearGradient>'
body = f'<rect width="{W}" height="{H}" fill="url(#sky)"/>'
# 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'<polygon points="{cx},{cy} {p(-.11)} {p(.11)}" fill="#FFFFFF" opacity=".13"/>'
body += f'<circle cx="{cx}" cy="{cy}" r="430" fill="#FFFFFF" opacity=".14"/><circle cx="{cx}" cy="{cy}" r="280" fill="#FFFFFF" opacity=".16"/>'
# Soft hills along the foot.
body += f'<path d="M0 640 Q320 560 640 636 T1280 610 V720 H0 Z" fill="#8FE07E"/><path d="M0 680 Q300 626 720 682 T1280 660 V720 H0 Z" fill="#6FCF6A"/>'
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'<g transform="translate({x:.2f},{y:.2f}) rotate({tilt:.2f}) scale({ZOOM * size:.4f}) translate({-FINAL["o"][0]},{-FINAL["o"][1]})">' + tots.planet(*FINAL['o'], 36, tots.SUN) + '</g>'
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 = '<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2F2D63"/><stop offset=".62" stop-color="#5A5296"/><stop offset="1" stop-color="#9184C2"/></linearGradient>'
dim = 1 - ease(between(t, 0, 1.5))
body = f'<rect width="{W}" height="{H}" fill="url(#sky)"/>'
# A low pink glow where the sun went down, and quiet hills.
body += f'<ellipse cx="640" cy="720" rx="900" ry="210" fill="#D9A6C8" opacity=".3"/><path d="M0 640 Q320 570 640 636 T1280 616 V720 H0 Z" fill="#413B7A"/><path d="M0 684 Q300 636 720 686 T1280 666 V720 H0 Z" fill="#332E66"/>'
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'<g transform="translate({x:.2f},{y + bob:.2f}) rotate({tilt:.2f}) scale({ZOOM * size:.4f}) translate({-FINAL["o"][0]},{-FINAL["o"][1]})">'
+ tots.planet(*FINAL['o'], 36, SOFT['o'], ring_colour=SOFT['ring'], eyes=eyes) + tots.nightcap(*FINAL['o'], 36, sway) + '</g>')
# 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'<rect width="{W}" height="{H}" fill="#1E1C40" opacity="{shade:.3f}"/>'
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]))
+125
View File
@@ -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 <morning.wav> <evening.wav>
"""
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('<hh', int(mix.left[i] / peak * fade * .7 * 32767), int(mix.right[i] / peak * fade * .7 * 32767))
out.writeframes(bytes(frames))
def morning(path):
mix, key, step = Mix(5.0), 67, .3 # From G: G A C A C E.
land = 2.5
start = land - step * 5
for i, c in enumerate((48, 52, 55, 60)): # Strings lead in as the planet floats up.
mix.add(.12 + i * .22, pluck(c, seed=i), .34, -.35)
for i, semis in enumerate(TUNE):
at = start + step * i
mix.add(at, marimba(key + semis, 2.2 if i == 5 else 1.2), 1.0 if i == 5 else .78, .08)
mix.add(at, marimba(key + semis - 12), .22, -.1)
for at, chord in ((start, (48, 55, 64)), (start + step * 2, (53, 60, 69)), (start + step * 4, (55, 62, 71)), (land, (48, 55, 64, 72))):
for n, note in enumerate(chord):
mix.add(at + n * .018, pluck(note, 1.8, seed=note), .28, .3 - n * .2)
mix.add(land, bell(84), .3, .25); mix.add(land + .012, bell(91), .13, -.25)
# A small answer while the logo holds, so the ending smiles.
for i, note in enumerate((79, 84)):
mix.add(land + .9 + i * .24, marimba(note), .36, .2)
mix.add(land + 1.14, bell(96), .1, -.2)
room(mix, (1871, 2467, 3119), .38, .1)
save(path, mix, .05, .4)
def evening(path):
mix, key, step = Mix(6.0), 72, .42 # The same tune, down at a gentle pace from C: C D F D F A.
land = 3.3
start = land - step * 5
mix.add(0, pad((53, 60, 65), 6.0, 1.6), .22)
for i, note in enumerate((65, 69)): # Two quiet notes as the stars come out.
mix.add(.45 + i * .4, music_box(note + 12), .22, .25 - i * .5)
for i, semis in enumerate(TUNE):
at = start + step * i
mix.add(at, music_box(key + semis, 3.2 if i == 5 else 2.0), .72 if i == 5 else .6, .06)
for at, chord in ((start, (53, 60, 69)), (start + step * 2, (58, 65, 70)), (start + step * 4, (55, 60, 67)), (land, (53, 60, 69, 72))):
for n, note in enumerate(chord):
mix.add(at + n * .045, piano(note), .2, -.2 + n * .13)
mix.add(land, tone(freq(93), 2.6, ((1, 1, 2.6), (2.76, .12, 6))), .09, .3)
room(mix, (2311, 3049, 3917, 4649), .62, .12)
save(path, mix, .4, 1.0)
morning(sys.argv[1])
evening(sys.argv[2])
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="384" height="128" viewBox="0 0 384 128"><defs><linearGradient id="silver" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient></defs><g transform="translate(10,2) scale(1.1)"><g transform="rotate(-5 40 52)"><path d="M15 24 Q40 19 65 23" fill="none" stroke="#0B1730" stroke-width="34" stroke-linecap="round" stroke-linejoin="round"/><path d="M40 24 Q42 50 39 78" fill="none" stroke="#0B1730" stroke-width="34" stroke-linecap="round" stroke-linejoin="round"/><path d="M15 24 Q40 19 65 23" fill="none" stroke="#FF6B5E" stroke-width="25" stroke-linecap="round" stroke-linejoin="round"/><path d="M40 24 Q42 50 39 78" fill="none" stroke="#FF6B5E" stroke-width="25" stroke-linecap="round" stroke-linejoin="round"/></g><g transform="rotate(-12 124 57.68)"><path d="M72.88 57.68 A51.12 7.92 0 0 1 175.12 57.68" fill="none" stroke="#0B1730" stroke-width="12" stroke-linecap="round"/><path d="M72.88 57.68 A51.12 7.92 0 0 1 175.12 57.68" fill="none" stroke="#F4F7FF" stroke-width="6" stroke-linecap="round"/></g><circle cx="124" cy="44" r="36" fill="#FFB52E" stroke="#0B1730" stroke-width="5"/><ellipse cx="111.76" cy="28.880000000000003" rx="4.32" ry="6.12" fill="#0B1730"/><ellipse cx="136.24" cy="28.880000000000003" rx="4.32" ry="6.12" fill="#0B1730"/><path d="M112.48 41.12 Q124 55.519999999999996 135.52 41.12" fill="none" stroke="#0B1730" stroke-width="4.32" stroke-linecap="round"/><g transform="rotate(-12 124 57.68)"><path d="M72.88 57.68 A51.12 7.92 0 0 0 175.12 57.68" fill="none" stroke="#0B1730" stroke-width="12" stroke-linecap="round"/><path d="M72.88 57.68 A51.12 7.92 0 0 0 175.12 57.68" fill="none" stroke="#F4F7FF" stroke-width="6" stroke-linecap="round"/></g><g transform="rotate(4 214 48)"><path d="M189 20 Q214 15 239 19" fill="none" stroke="#0B1730" stroke-width="34" stroke-linecap="round" stroke-linejoin="round"/><path d="M214 20 Q216 46 213 74" fill="none" stroke="#0B1730" stroke-width="34" stroke-linecap="round" stroke-linejoin="round"/><path d="M189 20 Q214 15 239 19" fill="none" stroke="#45C4FF" stroke-width="25" stroke-linecap="round" stroke-linejoin="round"/><path d="M214 20 Q216 46 213 74" fill="none" stroke="#45C4FF" stroke-width="25" stroke-linecap="round" stroke-linejoin="round"/></g><path d="M316 27 C302 14 268 16 270 35 C272 53 316 47 316 66 C316 85 278 86 266 70" fill="none" stroke="#0B1730" stroke-width="34" stroke-linecap="round" stroke-linejoin="round"/><path d="M316 27 C302 14 268 16 270 35 C272 53 316 47 316 66 C316 85 278 86 266 70" fill="none" stroke="#7BDC6B" stroke-width="25" stroke-linecap="round" stroke-linejoin="round"/></g></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="160" viewBox="0 0 512 160"><defs><linearGradient id="silver" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient></defs><g transform="translate(-5,1) scale(0.26)"><path d="M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z" fill="url(#silver)"/><path d="M129 369C102 399 111 425 161 428C267 432 431 340 445 289C455 250 405 250 369 261" fill="none" stroke="#FFB52E" stroke-width="11"/><circle cx="430" cy="282" r="9" fill="#FFB52E"/></g><text x="134" y="50" font-family="Barlow" font-size="18" font-weight="600" fill="#C9D3E3" letter-spacing="5">ORBIT</text><g transform="translate(134,58) scale(1.0)"><g transform="rotate(-5 40 52)"><path d="M15 24 Q40 19 65 23" fill="none" stroke="#0B1730" stroke-width="34" stroke-linecap="round" stroke-linejoin="round"/><path d="M40 24 Q42 50 39 78" fill="none" stroke="#0B1730" stroke-width="34" stroke-linecap="round" stroke-linejoin="round"/><path d="M15 24 Q40 19 65 23" fill="none" stroke="#FF6B5E" stroke-width="25" stroke-linecap="round" stroke-linejoin="round"/><path d="M40 24 Q42 50 39 78" fill="none" stroke="#FF6B5E" stroke-width="25" stroke-linecap="round" stroke-linejoin="round"/></g><g transform="rotate(-12 124 57.68)"><path d="M72.88 57.68 A51.12 7.92 0 0 1 175.12 57.68" fill="none" stroke="#0B1730" stroke-width="12" stroke-linecap="round"/><path d="M72.88 57.68 A51.12 7.92 0 0 1 175.12 57.68" fill="none" stroke="#F4F7FF" stroke-width="6" stroke-linecap="round"/></g><circle cx="124" cy="44" r="36" fill="#FFB52E" stroke="#0B1730" stroke-width="5"/><ellipse cx="111.76" cy="28.880000000000003" rx="4.32" ry="6.12" fill="#0B1730"/><ellipse cx="136.24" cy="28.880000000000003" rx="4.32" ry="6.12" fill="#0B1730"/><path d="M112.48 41.12 Q124 55.519999999999996 135.52 41.12" fill="none" stroke="#0B1730" stroke-width="4.32" stroke-linecap="round"/><g transform="rotate(-12 124 57.68)"><path d="M72.88 57.68 A51.12 7.92 0 0 0 175.12 57.68" fill="none" stroke="#0B1730" stroke-width="12" stroke-linecap="round"/><path d="M72.88 57.68 A51.12 7.92 0 0 0 175.12 57.68" fill="none" stroke="#F4F7FF" stroke-width="6" stroke-linecap="round"/></g><g transform="rotate(4 214 48)"><path d="M189 20 Q214 15 239 19" fill="none" stroke="#0B1730" stroke-width="34" stroke-linecap="round" stroke-linejoin="round"/><path d="M214 20 Q216 46 213 74" fill="none" stroke="#0B1730" stroke-width="34" stroke-linecap="round" stroke-linejoin="round"/><path d="M189 20 Q214 15 239 19" fill="none" stroke="#45C4FF" stroke-width="25" stroke-linecap="round" stroke-linejoin="round"/><path d="M214 20 Q216 46 213 74" fill="none" stroke="#45C4FF" stroke-width="25" stroke-linecap="round" stroke-linejoin="round"/></g><path d="M316 27 C302 14 268 16 270 35 C272 53 316 47 316 66 C316 85 278 86 266 70" fill="none" stroke="#0B1730" stroke-width="34" stroke-linecap="round" stroke-linejoin="round"/><path d="M316 27 C302 14 268 16 270 35 C272 53 316 47 316 66 C316 85 278 86 266 70" fill="none" stroke="#7BDC6B" stroke-width="25" stroke-linecap="round" stroke-linejoin="round"/></g></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256"><defs><linearGradient id="silver" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient></defs><g transform="rotate(-12 120 177.36)"><path d="M17.760000000000005 177.36 A102.24 15.84 0 0 1 222.24 177.36" fill="none" stroke="#0B1730" stroke-width="12" stroke-linecap="round"/><path d="M17.760000000000005 177.36 A102.24 15.84 0 0 1 222.24 177.36" fill="none" stroke="#F4F7FF" stroke-width="6" stroke-linecap="round"/></g><circle cx="120" cy="150" r="72" fill="#FFB52E" stroke="#0B1730" stroke-width="5"/><ellipse cx="95.52" cy="119.76" rx="8.64" ry="12.24" fill="#0B1730"/><ellipse cx="144.48" cy="119.76" rx="8.64" ry="12.24" fill="#0B1730"/><path d="M96.96000000000001 144.24 Q120 173.04 143.04 144.24" fill="none" stroke="#0B1730" stroke-width="8.64" stroke-linecap="round"/><g transform="rotate(-12 120 177.36)"><path d="M17.760000000000005 177.36 A102.24 15.84 0 0 0 222.24 177.36" fill="none" stroke="#0B1730" stroke-width="12" stroke-linecap="round"/><path d="M17.760000000000005 177.36 A102.24 15.84 0 0 0 222.24 177.36" fill="none" stroke="#F4F7FF" stroke-width="6" stroke-linecap="round"/></g><path d="M62.4 106.8 Q89.8 26.2 143.0 33.4 Q186.2 40.6 206.4 88.1 Q171.8 63.6 177.6 106.8 Z" fill="#A99BE8" stroke="#0B1730" stroke-width="5" stroke-linejoin="round"/><path d="M65.3 105.4 Q120.0 83.8 174.7 105.4" fill="none" stroke="#0B1730" stroke-width="24.48" stroke-linecap="round" stroke-linejoin="round"/><path d="M65.3 105.4 Q120.0 83.8 174.7 105.4" fill="none" stroke="#EEE9FF" stroke-width="14.4" stroke-linecap="round" stroke-linejoin="round"/><polygon points="206.4,73.7 210.3,82.7 220.1,83.6 212.7,90.1 214.9,99.7 206.4,94.7 197.9,99.7 200.1,90.1 192.7,83.6 202.5,82.7" fill="#FFE08A" stroke="#0B1730" stroke-width="4.6" stroke-linejoin="round" paint-order="stroke"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+7
View File
@@ -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.