diff --git a/android/app/src/main/java/tv/orbit/classic/BlockIdents.kt b/android/app/src/main/java/tv/orbit/classic/BlockIdents.kt index 08de9cd..3ab888f 100644 --- a/android/app/src/main/java/tv/orbit/classic/BlockIdents.kt +++ b/android/app/src/main/java/tv/orbit/classic/BlockIdents.kt @@ -57,8 +57,13 @@ internal val BlockIdents: Map = mapOf( ADVENT_DOOR_25_KEY to BlockIdent(R.raw.block_advent_door25, R.raw.block_advent_door25_sting, BlockOverlay.AdventDoor25), // After Dark: The Naughty List (approved 4 November 2026): the whole title is in the clip. NAUGHTY_KEY to BlockIdent(R.raw.block_afterdark_naughty, R.raw.block_afterdark_naughty_sting), + // Saturday Mornings (Paul, 6 November 2026): the weekend cartoons on Heroes, Orbit X and Rewind; Sundays read SUNDAY. + SATURDAY_MORNINGS to BlockIdent(R.raw.block_saturday_mornings, R.raw.block_saturday_mornings_sting), ) + RingXNights.keys.associateWith { BlockIdent(R.raw.block_ringx_nights, R.raw.block_ringx_nights_sting, BlockOverlay.RingXNight) } +internal const val SATURDAY_MORNINGS = "saturday.mornings" +private val SundayMornings = BlockIdent(R.raw.block_sunday_mornings, R.raw.block_saturday_mornings_sting) + /** Orbit's own names for Ring X's weekly nights (approved 3 November 2026), and each night's rope colour. */ internal val RingXNights: Map> get() = mapOf( "ringx.monday" to ("MONDAY NIGHT WARS" to 0xFFE81C30), "ringx.tuesday" to ("TUESDAY TITLE FIGHT" to 0xFFFFC21E), @@ -70,7 +75,9 @@ internal val RingXNights: Map> get() = mapOf( * anything else, including the second programme inside the same block, gets the channel's ident (null). */ internal fun blockIdentFor(current: SampleBroadcast, next: SampleBroadcast, enabled: Boolean): BlockIdent? = - if (!enabled || next.presentation.isEmpty() || next.presentation == current.presentation) null else BlockIdents[next.presentation] + if (!enabled || next.presentation.isEmpty() || next.presentation == current.presentation) null + else if (next.presentation == SATURDAY_MORNINGS && java.time.Instant.ofEpochMilli(next.startMs).atZone(java.time.ZoneId.systemDefault()).dayOfWeek == java.time.DayOfWeek.SUNDAY) SundayMornings + else BlockIdents[next.presentation] /** A programme title made fit for a lower third: the guide's own clean title, or nothing if there isn't one. */ internal fun eventTitle(next: SampleBroadcast): String? = guideTitleParts(next.media.item.title).first.trim().takeIf { it.isNotEmpty() } diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelIdentities.kt b/android/app/src/main/java/tv/orbit/classic/ChannelIdentities.kt index 6610f94..d08cfc2 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelIdentities.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelIdentities.kt @@ -182,8 +182,9 @@ private val HeroesMarvel = listOf("X-Men: The Animated Series", "Spider-Man: The private val HeroesAfterSchool = listOf("Batman: The Animated Series", "Superman: The Animated Series", "Batman Beyond", "Justice League", "Justice League Unlimited", "Static Shock", "Teen Titans") internal val HeroesShows get() = (HeroesCore + HeroesSupporting).distinct() internal val HeroesDayParts get() = listOf( - DayPart(7, 11, HeroesMarvel, days = setOf(DayOfWeek.SATURDAY)), - DayPart(7, 11, HeroesSaturday, days = setOf(DayOfWeek.SUNDAY)), + // Saturday Mornings' ident as the weekend cartoons begin (Paul, 6 November 2026); the key names the ident only. + DayPart(7, 11, HeroesMarvel, days = setOf(DayOfWeek.SATURDAY), key = SATURDAY_MORNINGS), + DayPart(7, 11, HeroesSaturday, days = setOf(DayOfWeek.SUNDAY), key = SATURDAY_MORNINGS), DayPart(6, 15, HeroesShows), DayPart(15, 18, HeroesAfterSchool), DayPart(18, 22, HeroesCore + listOf("Young Justice", "The Avengers: Earth's Mightiest Heroes", "The Spectacular Spider-Man")), DayPart(22, 6, HeroesShows)) /** Self-contained episodes mix. Shows with story arcs stay in order. */ @@ -201,8 +202,8 @@ private val OrbitXGuests = listOf("X-Men: The Animated Series", "Spider-Man: The private val OrbitXEarlierNames = listOf("Digimon Adventure", "Mighty Morphin Power Rangers", "G.I. Joe: A Real American Hero") internal val OrbitXShows get() = (OrbitXCore + OrbitXSupporting + OrbitXGuests + OrbitXEarlierNames).distinct() internal val OrbitXDayParts get() = listOf( - DayPart(7, 12, OrbitXCore + OrbitXEarlierNames + OrbitXGuests, days = setOf(DayOfWeek.SATURDAY)), - DayPart(7, 12, OrbitXSaturday, days = setOf(DayOfWeek.SUNDAY)), + DayPart(7, 12, OrbitXCore + OrbitXEarlierNames + OrbitXGuests, days = setOf(DayOfWeek.SATURDAY), key = SATURDAY_MORNINGS), + DayPart(7, 12, OrbitXSaturday, days = setOf(DayOfWeek.SUNDAY), key = SATURDAY_MORNINGS), DayPart(15, 18, OrbitXCore + OrbitXEarlierNames + OrbitXGuests, days = Weekdays), DayPart(6, 18, OrbitXCore + OrbitXSupporting + OrbitXEarlierNames), DayPart(18, 23, OrbitXShows), DayPart(23, 6, OrbitXCore + OrbitXSupporting + OrbitXEarlierNames)) internal val OrbitXMixed get() = setOf("Biker Mice from Mars", "Teenage Mutant Ninja Turtles", "The Transformers", "He-Man and the Masters of the Universe", "ThunderCats", @@ -220,7 +221,7 @@ private val RewindSupporting = listOf("Rugrats", "Hey Arnold!", "Doug", "CatDog" private val RewindNewer = listOf("Regular Show", "Gravity Falls") internal val RewindShows get() = (RewindCore + RewindSupporting + RewindNewer).distinct() internal val RewindDayParts get() = listOf( - DayPart(7, 12, RewindSaturday, days = Weekend), + DayPart(7, 12, RewindSaturday, days = Weekend, key = SATURDAY_MORNINGS), DayPart(6, 9, RewindCore + listOf("Rugrats", "Hey Arnold!", "Doug")), DayPart(9, 15, RewindShows), DayPart(15, 19, RewindCore), DayPart(19, 23, RewindShows), DayPart(23, 6, RewindCore)) internal val RewindMixed get() = RewindShows.toSet() - "Gravity Falls" diff --git a/android/app/src/main/java/tv/orbit/classic/InstallSnapshot.kt b/android/app/src/main/java/tv/orbit/classic/InstallSnapshot.kt index f18af25..c1a11c5 100644 --- a/android/app/src/main/java/tv/orbit/classic/InstallSnapshot.kt +++ b/android/app/src/main/java/tv/orbit/classic/InstallSnapshot.kt @@ -13,6 +13,7 @@ import java.security.MessageDigest * - `snapshot`: copies the schedules and settings into Orbit's own folder on shared storage, with the library's and * sign-in's checksums, for adb to pull and compare. Nothing is read out of them here. * - `rollback `: copies the schedules aside as schedule-rollback-, as every install does. + * - `catalogue`: a read-only copy of the library catalogue beside it, for checking new channels' titles against the library. * - `clear`: removes the copy once it's been pulled. * * It only ever copies; it never changes the schedules, the library or the sign-in. @@ -48,6 +49,12 @@ internal object InstallSnapshot { aside.deleteRecursively(); schedule.copyRecursively(aside, overwrite = true) "aside ${aside.walk().count { it.isFile }}" } + "catalogue" -> { + val db = File(data, "no_backup/catalogue.db") + if (!db.exists()) return "no catalogue" + db.copyTo(File(out, "catalogue.db"), overwrite = true) + "copied catalogue" + } "clear" -> { out.deleteRecursively(); "cleared" } else -> "unknown" } diff --git a/android/app/src/main/res/raw/block_saturday_mornings.mp4 b/android/app/src/main/res/raw/block_saturday_mornings.mp4 new file mode 100644 index 0000000..2b24ae5 Binary files /dev/null and b/android/app/src/main/res/raw/block_saturday_mornings.mp4 differ diff --git a/android/app/src/main/res/raw/block_saturday_mornings_sting.m4a b/android/app/src/main/res/raw/block_saturday_mornings_sting.m4a new file mode 100644 index 0000000..e099e7d Binary files /dev/null and b/android/app/src/main/res/raw/block_saturday_mornings_sting.m4a differ diff --git a/android/app/src/main/res/raw/block_sunday_mornings.mp4 b/android/app/src/main/res/raw/block_sunday_mornings.mp4 new file mode 100644 index 0000000..fe12952 Binary files /dev/null and b/android/app/src/main/res/raw/block_sunday_mornings.mp4 differ diff --git a/android/app/src/test/java/tv/orbit/classic/PresentationTest.kt b/android/app/src/test/java/tv/orbit/classic/PresentationTest.kt index 21aeade..bf02de1 100644 --- a/android/app/src/test/java/tv/orbit/classic/PresentationTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/PresentationTest.kt @@ -19,8 +19,9 @@ class PresentationTest { @Test fun everyApprovedBlockKeyHasItsIdent() { // Takeovers (6 November 2026) have their own drawn ident instead of a filmed one. val keys = SmartChannels.flatMap { it.dayParts }.map { it.key }.filter { it.isNotEmpty() }.toSet() - Takeovers.map { it.key }.toSet() - // Riot 3, After Dark 3, Horror, Gold, Three, Vortex 4 (Movie Night, Prime, Afterburn and its premiere), Orbit 2 2, Ring X 7 (six nights and PPV), Terra. - assertEquals(23, keys.size) + // Riot 3, After Dark 3, Horror, Gold, Three, Vortex 4 (Movie Night, Prime, Afterburn and its premiere), Orbit 2 2, Ring X 7 (six nights and PPV), Terra, + // and Saturday Mornings, shared by Heroes, Orbit X and Rewind. + assertEquals(24, keys.size) assertEquals(emptySet(), keys - BlockIdents.keys) assertTrue(keys.all { PRESENTATION_KEY.matches(it) }) } @@ -115,4 +116,12 @@ class PresentationTest { val long = "NJPW Dominion 6.9 in Osaka-jo Hall and a very long subtitle that runs on" assertEquals(long, eventTitle(broadcast("ringx.ppv", long))) } + + @Test fun saturdayMorningsReadsSundayOnSundays() { + val sunday = java.time.LocalDate.of(2026, 11, 8).atTime(7, 0).atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli() + val saturday = sunday - 24 * 60 * 60_000L + fun at(ms: Long, key: String) = SampleBroadcast(ScheduledMedia(SampleItem("Show / S1 E1 / One", "Episode", 30, "", "i", 30 * 60_000L), "c", 30 * 60_000L), ms, key) + assertEquals(R.raw.block_saturday_mornings, blockIdentFor(at(saturday - 1, ""), at(saturday, SATURDAY_MORNINGS), true)?.video) + assertEquals(R.raw.block_sunday_mornings, blockIdentFor(at(sunday - 1, ""), at(sunday, SATURDAY_MORNINGS), true)?.video) + } } diff --git a/design/channels/build-saturday-mornings.py b/design/channels/build-saturday-mornings.py new file mode 100644 index 0000000..9d017e1 --- /dev/null +++ b/design/channels/build-saturday-mornings.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +Saturday Mornings, the weekend cartoon block's own ident (Paul, 6 November 2026): 4 seconds, as every block ident is. +The sun comes up over a cartoon morning sky, Ollie bounces in, Zip streaks across, and SATURDAY MORNINGS lands; a Sunday +version reads SUNDAY MORNINGS for the Sunday blocks. With a short, cheerful sting synthesised here (no samples). + +Built with the EffectCraft pipeline from design/motion/build-moments.py. Writes saturday-mornings/exports (the clips, +stings and stills) and a review sheet; nothing here is in the app until Paul approves it. +""" +import importlib.util, math, struct, subprocess, tempfile, wave +from pathlib import Path +from PIL import Image, ImageDraw, ImageFilter + +HERE = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location('moments', HERE.parent / 'motion' / 'build-moments.py') +m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m) +OUT = HERE / 'saturday-mornings' +m.OUT, m.SRC, m.REVIEW = OUT / 'exports', OUT / 'sources', OUT / 'review' +W, H, END = m.W, m.H, 4.0 + + +def sky(): + im = Image.new('RGBA', (W, H)); d = ImageDraw.Draw(im) + top, low = (86, 170, 255), (255, 214, 150) + for y in range(H): + u = (y / H) ** 1.6 + d.line([(0, y), (W, y)], fill=tuple(int(top[i] + (low[i] - top[i]) * u) for i in range(3)) + (255,)) + return im + + +def sun(): + im = m.canvas(); d = ImageDraw.Draw(im); cx, cy = W / 2, 780 + for k in range(16): + a = k * math.pi / 8 + d.polygon([(cx, cy), (cx + 900 * math.cos(a - .09), cy + 900 * math.sin(a - .09)), (cx + 900 * math.cos(a + .09), cy + 900 * math.sin(a + .09))], fill=(255, 236, 160, 45)) + d.ellipse((cx - 190, cy - 190, cx + 190, cy + 190), fill=(255, 205, 64, 255)) + return m.glow(im, 18) + + +def clouds(): + im = m.canvas(); d = ImageDraw.Draw(im) + for cx, cy, s in [(260, 260, 1.0), (1560, 200, 1.2), (1250, 420, .8), (480, 520, .7)]: + for ox, oy, r in [(-60, 10, 55), (0, -20, 70), (65, 5, 58), (120, 20, 42), (-110, 25, 40)]: + d.ellipse((cx + ox * s - r * s, cy + oy * s - r * s, cx + ox * s + r * s, cy + oy * s + r * s), fill=(255, 255, 255, 235)) + return im + + +def hills(): + im = m.canvas(); d = ImageDraw.Draw(im) + d.ellipse((-300, 820, 1100, 1500), fill=(92, 200, 120, 255)); d.ellipse((800, 860, 2300, 1560), fill=(70, 176, 104, 255)) + return im + + +def title(word): + im = m.canvas() + f = m.ANTON(190) + for dx, dy in [(-7, 0), (7, 0), (0, -7), (0, 7), (-5, -5), (5, 5), (-5, 5), (5, -5)]: + layer = m.canvas(); m.centred_text(layer, 470 + dy, word, f, (7, 22, 50, 255), spacing=8) + im = Image.alpha_composite(im, layer.transform(im.size, Image.AFFINE, (1, 0, -dx, 0, 1, 0))) + m.centred_text(im, 470, word, f, (255, 222, 89, 255), spacing=8) + return im + + +def mornings(): + im = m.canvas(); f = m.ANTON(120) + for dx, dy in [(-6, 0), (6, 0), (0, -6), (0, 6)]: + layer = m.canvas(); m.centred_text(layer, 640 + dy, 'MORNINGS', f, (7, 22, 50, 255), spacing=10) + im = Image.alpha_composite(im, layer.transform(im.size, Image.AFFINE, (1, 0, -dx, 0, 1, 0))) + m.centred_text(im, 640, 'MORNINGS', f, (255, 255, 255, 255), spacing=10) + return im + + +def build(day): + c = m.Comp(f'{day.lower()}_mornings', END, background=(86, 170, 255)) + c.image(sky(), 'Sky') + c.image(sun(), 'Sun') + c.key('Sun', 'transform/position', m.ease_out(0, 1.1, [640, 560, 0], [640, 360, 0], 6)) + c.cmd('prop.set', layer='Sun', path='transform/anchor', value=[960, 780, 0]) + c.key('Sun', 'transform/rotation', [(0, 0), (END, 14)]) + c.image(clouds(), 'Clouds') + c.key('Clouds', 'transform/position', [(0, [600, 360, 0]), (END, [660, 360, 0])]) + c.image(hills(), 'Hills') + c.image(m.host('Ollie', (W / 2 - 520, 830), 70), 'Ollie') + c.key('Ollie', 'transform/position', [(0, [100, 360, 0])] + [(0.35 + k * .1, [100 + 540 * min(1, (k + 1) / 8), 360 - 40 * abs(math.sin((k + 1) * 1.2)), 0]) for k in range(8)]) + zip_ = m.host('Zip', (W / 2, 260), 50) + trail = m.canvas(); ImageDraw.Draw(trail).rounded_rectangle((W / 2 - 900, 248, W / 2 - 40, 272), 12, fill=(255, 79, 163, 150)) + c.image(Image.alpha_composite(m.glow(trail, 10), zip_), 'Zip') + c.key('Zip', 'transform/position', [(0, [-700, 360, 0]), (.9, [-700, 360, 0])] + m.ease_out(.9, 1.5, [-700, 360, 0], [2300, 360, 0], 5)[1:]) + c.image(title(day.upper()), 'Day') + c.cmd('prop.set', layer='Day', path='transform/anchor', value=[960, 420, 0]) + c.cmd('prop.set', layer='Day', path='transform/position', value=[640, 280, 0]) + c.key('Day', 'transform/opacity', [(0, 0), (1.25, 0), (1.35, 100)]) + c.key('Day', 'transform/scale', [(1.25, [30, 30, 100]), (1.45, [74, 74, 100]), (1.58, [66.67, 66.67, 100])]) + c.image(mornings(), 'Mornings') + c.key('Mornings', 'transform/opacity', [(0, 0), (1.6, 0), (1.75, 100)]) + c.key('Mornings', 'transform/position', m.ease_out(1.6, 1.95, [640, 400, 0], [640, 360, 0], 5)) + for name in ('Sky', 'Sun', 'Clouds', 'Hills', 'Ollie', 'Day', 'Mornings'): + c.key(name, 'transform/opacity', [(END - .25, 100), (END, 0)]) + return c.render(still_at=3.0) + + +def sting(path): + rate = 44100; notes = [(0.35, 523.25), (0.55, 659.25), (0.75, 783.99), (1.3, 1046.5), (1.42, 1318.5)] + out = [0.0] * int(rate * END) + for start, f in notes: + n = int(rate * .5) + for i in range(n): + t = i / rate; env = min(1, t / .01) * math.exp(-t * 5) + out[int(start * rate) + i] += .3 * env * (math.sin(2 * math.pi * f * t) + .35 * math.sin(4 * math.pi * f * t)) + peak = max(abs(s) for s in out) + with tempfile.NamedTemporaryFile(suffix='.wav') as tmp: + with wave.open(tmp.name, 'wb') as w: + w.setnchannels(1); w.setsampwidth(2); w.setframerate(rate) + w.writeframes(b''.join(struct.pack('