0.81.0: Orbit Tots, with shuffled episodes, day-part idents and Wind Down Now
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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]))
|
||||
@@ -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 |
|
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 |
@@ -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.
|
||||
|
||||