diff --git a/android/app/src/main/java/tv/orbit/classic/CatchUp.kt b/android/app/src/main/java/tv/orbit/classic/CatchUp.kt index 2ccacf1..d51a34c 100644 --- a/android/app/src/main/java/tv/orbit/classic/CatchUp.kt +++ b/android/app/src/main/java/tv/orbit/classic/CatchUp.kt @@ -72,7 +72,7 @@ internal class CatchUpShelf(val title: String, val entries: List) */ internal fun catchUpShelves(channels: List, nowMs: Long, zone: ZoneId = ZoneId.systemDefault()): List { val all = channels.filterIsInstance().filter { it.number != DRIVE_IN_NUMBER }.flatMap { channel -> - channel.plan.entries.filter { it.endMs <= nowMs && it.startMs >= nowMs - CATCH_UP_MS }.map { e -> catchUpEntry(channel, SampleBroadcast(e.media, e.startMs, e.presentation)) } + channel.plan.entries.filter { it.endMs <= nowMs && it.startMs >= nowMs - CATCH_UP_MS && !kidsAsleep(takeoverOf(channel.number, it)) }.map { e -> catchUpEntry(channel, SampleBroadcast(e.media, e.startMs, e.presentation)) } }.sortedByDescending { it.show.endMs }.distinctBy { it.show.media.item.id } val today = Instant.ofEpochMilli(nowMs).atZone(zone).toLocalDate() fun day(e: CatchUpEntry) = Instant.ofEpochMilli(e.show.startMs).atZone(zone).toLocalDate() 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 52e3a12..1f4b228 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelIdentities.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelIdentities.kt @@ -215,12 +215,16 @@ private val ToonZCore = listOf("Looney Tunes", "Tom and Jerry", "Scooby-Doo, Whe private val ToonZSupporting = listOf("Wacky Races", "Top Cat", "Hong Kong Phooey", "The Huckleberry Hound Show", "The Looney Tunes Show", "Dastardly & Muttley in Their Flying Machines", "Dastardly and Muttley in Their Flying Machines", "The Pink Panther", "The Pink Panther Show", "Merrie Melodies", "The Bugs Bunny Show", "The Tom and Jerry Show", "Woody Woodpecker") private val ToonZTeatime = listOf("Danger Mouse", "Count Duckula", "Bananaman") -internal val ToonZShows get() = (ToonZCore + ToonZSupporting + ToonZTeatime).distinct() +/** ToonZ Late (Paul, 6 November 2026): grown-up animation from 22:00 to 05:00, titles as the library spells them. */ +internal val ToonZLateShows = listOf("Futurama", "The Simpsons", "Family Guy", "American Dad!", "King of the Hill", "Rick and Morty", "Bob's Burgers", "Archer (2009)", + "BoJack Horseman", "Beavis and Butt-Head", "Daria", "Solar Opposites", "Disenchantment", "Final Space", "Harley Quinn", "Clone High", "Duckman", "The Critic", + "Aqua Teen Hunger Force", "South Park") +internal val ToonZShows get() = (ToonZCore + ToonZSupporting + ToonZTeatime + ToonZLateShows).distinct() internal val ToonZDayParts get() = listOf( DayPart(6, 10, ToonZCore + ToonZSupporting), DayPart(10, 16, ToonZCore + ToonZSupporting), // Teatime is the British trio's block, so there they're level with the core (approved 4 November 2026). DayPart(16, 18, ToonZTeatime + ToonZCore, weights = ToonZTeatime.associate { seriesKey(it) to TIER_CORE }), - DayPart(18, 0, ToonZCore + ToonZSupporting), DayPart(0, 6, ToonZShows)) + DayPart(18, 22, ToonZCore + ToonZSupporting), takeoverPart(TOONZ_LATE), DayPart(5, 6, ToonZCore + ToonZSupporting)) internal val ToonZMixed get() = ToonZShows.toSet() /** ToonZ's tiers, from docs/channels/toonz.md: the core 3, everything else 2. The teatime trio's own block weights them 3 there. */ internal val ToonZTiers: Map get() = ToonZCore.associate { seriesKey(it) to TIER_CORE } @@ -233,10 +237,14 @@ private val TerraWider = listOf("Hostile Planet", "Wild China", "Wild Russia", " "Spy in the Wild", "Serengeti", "Eden: Untamed Planet", "Natural World", "Great Barrier Reef with David Attenborough", "Big Cat Diary") /** Names on the earlier list the library doesn't hold under those spellings, kept in case they turn up. */ private val TerraEarlierNames = listOf("Our Planet II", "Dynasties", "Dynasties II", "Africa", "South Pacific", "Great Barrier Reef") -internal val TerraShows get() = (TerraLandmarks + TerraWider + TerraEarlierNames).distinct() +/** Terra Starlight (Paul, 6 November 2026): space and night-sky documentaries from 22:00 to 06:00. */ +internal val TerraStarlightShows = listOf("Cosmos", "Cosmos (2014)", "The Planets", "The Sky at Night", "Wonders of the Solar System", "Wonders of the Universe", + "How the Universe Works", "Into the Universe with Stephen Hawking", "The Universe", "One Strange Rock", "Through the Wormhole") +internal val TerraShows get() = (TerraLandmarks + TerraWider + TerraEarlierNames + TerraStarlightShows).distinct() +private val TerraDayShows get() = (TerraLandmarks + TerraWider + TerraEarlierNames).distinct() internal val TerraDayParts get() = listOf( DayPart(20, 22, TerraLandmarks, days = setOf(DayOfWeek.SUNDAY), onTime = true, key = "terra.sunday"), - DayPart(6, 18, TerraShows), DayPart(18, 23, TerraLandmarks), DayPart(23, 6, TerraShows)) + DayPart(6, 18, TerraDayShows), DayPart(18, 22, TerraLandmarks), takeoverPart(TERRA_STARLIGHT)) /** Wild: people meeting nature and surviving it. The presenters and participants are the story. */ private val WildSurvival = listOf("Man vs. Wild", "Running Wild with Bear Grylls", "Survivorman", "Dual Survival", "Naked and Afraid", "Alone", "Survival with Ray Mears", @@ -359,10 +367,13 @@ private val JuniorAfterSchool = listOf("Grange Hill", "Byker Grove", "The Story "The Worst Witch", "The Demon Headmaster", "My Parents Are Aliens", "Bernard's Watch", "Raven", "Knightmare", "Maid Marian and Her Merry Men", "Rentaghost", "The Animals of Farthing Wood", "Grizzly Tales for Gruesome Kids", "ChuckleVision") private val JuniorTeatime = listOf("Goosebumps", "Are You Afraid of the Dark?", "Thunderbirds", "Stingray", "Captain Scarlet and the Mysterons", "Horrible Histories") -internal val JuniorShows get() = (JuniorBreakfast + JuniorDaytime + JuniorAfterSchool + JuniorTeatime).distinct() +/** Nite Owl (Paul, 6 November 2026): classic sitcoms on Junior from 21:00 till dawn, Orbit's Nick at Nite. */ +internal val NiteOwlShows = listOf("Cheers", "Frasier", "Taxi", "M*A*S*H", "Fawlty Towers", "Only Fools and Horses", "Seinfeld", "The Golden Girls", "Porridge", + "Dad's Army", "Blackadder", "Red Dwarf", "Open All Hours", "Night Court", "Father Ted", "Black Books", "Steptoe and Son", "Keeping Up Appearances") +internal val JuniorShows get() = (JuniorBreakfast + JuniorDaytime + JuniorAfterSchool + JuniorTeatime + NiteOwlShows).distinct() internal val JuniorDayParts get() = listOf( DayPart(6, 9, JuniorBreakfast), DayPart(9, 15, JuniorDaytime + JuniorAfterSchool), DayPart(15, 18, JuniorAfterSchool), DayPart(18, 20, JuniorTeatime), - DayPart(20, 6, JuniorDaytime + JuniorAfterSchool)) + DayPart(20, 21, JuniorDaytime + JuniorAfterSchool), takeoverPart(NITE_OWL)) /** Story-driven series run in order; comedy, animation and the classics mix. */ internal val JuniorMixed get() = JuniorShows.toSet() - setOf("Grange Hill", "Byker Grove", "The Story of Tracy Beaker", "Tracy Beaker Returns", "The Demon Headmaster", "Round the Twist", "The Worst Witch", "Maid Marian and Her Merry Men", "The Animals of Farthing Wood") diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt index df10f5f..abf8c89 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt @@ -199,7 +199,7 @@ internal val SmartChannels = listOf( tierWeights = TotsTiers, catalogueScaleHours = CATALOGUE_SCALE_HOURS, storyGrouping = true), ChannelProgramming(106, "Paradox", ParadoxShows, seriesCount = 20, finishTogether = true, exactTitles = true, dayParts = ParadoxDayParts, shuffleTitles = (ParadoxStandalones + ParadoxAdult).toSet(), tierWeights = ParadoxTiers, storyGrouping = true)) + - 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, + ChannelPresets.filter { it.smartOnly }.map { ChannelProgramming(it.number, it.name, it.searches, seriesCount = if (it.films) it.searches.size else maxOf(60, it.searches.size), finishTogether = !it.films, exactTitles = true, filmRestMs = if (it.films) 7 * 24 * 60 * 60_000L else null, suggestionRules = SmartChannelRules[it.number].orEmpty(), dayParts = SmartChannelParts[it.number].orEmpty(), shuffleTitles = SmartChannelMixed[it.number].orEmpty(), channelFilms = if (it.number == 401) PpvSunday else emptyList(), tierWeights = SmartChannelTiers[it.number], adaptiveFilmRest = it.number in AdaptiveFilmRestChannels, diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt index 169de53..01cc882 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp @@ -480,7 +481,11 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl 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 }?.takeIf { logoSetting } - if (bug != null && ready && !infoVisible && failure == null && squeeze == 0f) + // During a takeover, its own mark in the corner instead of the channel's. + val takeoverNow = scheduled?.let { takeoverOf(it.channel.number, it.programme) } + if (takeoverNow != null && logoSetting && ready && !infoVisible && failure == null && squeeze == 0f) + TakeoverMark(takeoverNow, Modifier.align(Alignment.BottomStart).padding(start = 40.dp, bottom = 28.dp).alpha(.85f), height = 26.dp, textSize = 20.sp) + else if (bug != null && ready && !infoVisible && failure == null && squeeze == 0f) 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 && squeeze == 0f) ClassicProgrammeStrip(plan.item, playing, Modifier.align(Alignment.BottomCenter).fillMaxWidth(.86f).padding(bottom = 26.dp), scheduled = scheduled) diff --git a/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt index 943bde5..f648e5b 100644 --- a/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt +++ b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt @@ -162,7 +162,7 @@ internal fun GuideGrid(channels: List, selectedNumber: Int, modif Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { if (branding != null) Image(painterResource(branding.logo), branding.displayName, Modifier.width(96.dp).height(30.dp)) else Text(ChannelBrands.displayName(channel), color = Color.White, fontSize = 16.sp) - val (focusedName, focusedDetail) = guideTitleParts(focused.media.item.title) + val (focusedName, focusedDetail) = if (kidsAsleep(takeoverOf(channel.number, focused))) "Asleep till 6am" to "Back for Kids in the morning" else guideTitleParts(focused.media.item.title) Text(focusedName, color = Color.White, fontFamily = Anton, fontSize = 20.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) if (focusedDetail != null) Text(focusedDetail, color = Color.White, fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) Text("${guideDayPrefix(focused.startMs, now)}${clock(focused.startMs)} to ${clock(focused.endMs)} • ${channel.number} ${ChannelBrands.displayName(channel)}" + @@ -216,9 +216,12 @@ internal fun GuideGrid(channels: List, selectedNumber: Int, modif val index = firstRow + offset Row(Modifier.fillMaxWidth().height(38.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) { val mark = ChannelBrands.forNumber(line.number) + // A takeover on at the guide's time wears its own mark (Paul, 6 November 2026). + val takeover = takeoverOf(line.number, line.at(maxOf(windowStart, now))) Row(Modifier.width(130.dp).fillMaxHeight().background(if (index == row) Blue else Panel, androidx.compose.foundation.shape.RoundedCornerShape(4.dp)).padding(start = 6.dp), verticalAlignment = Alignment.CenterVertically) { Text(line.number.toString(), Modifier.width(30.dp), color = Yellow, fontFamily = BarlowCondensed, fontSize = 13.sp) - if (mark != null) Image(painterResource(mark.logo), mark.displayName, Modifier.width(88.dp).height(28.dp)) + if (takeover != null) TakeoverMark(takeover, Modifier.width(92.dp), height = 18.dp, textSize = 13.sp) + else if (mark != null) Image(painterResource(mark.logo), mark.displayName, Modifier.width(88.dp).height(28.dp)) else Text(ChannelBrands.displayName(line), color = Color.White, fontSize = 13.sp, maxLines = 1) } BoxWithConstraints(Modifier.weight(1f).fillMaxHeight()) { @@ -237,7 +240,9 @@ internal fun GuideGrid(channels: List, selectedNumber: Int, modif val ink = if (isFocus) Color.White else if (past) Muted else Color.White if (elapsed > 0.dp) Box(Modifier.align(Alignment.BottomStart).width(elapsed).height(3.dp).background(Yellow)) Row(Modifier.padding(horizontal = 6.dp), verticalAlignment = Alignment.CenterVertically) { - Text(if (cellWidth >= GuideTitleMinWidth) guideTitleParts(cell.show.media.item.title).first else "…", Modifier.weight(1f, fill = false), color = ink, fontSize = 13.sp, + // Kids never sees what a grown-up takeover is showing. + val asleep = kidsAsleep(takeoverOf(line.number, cell.show)) + Text(if (cellWidth < GuideTitleMinWidth) "…" else if (asleep) "Asleep till 6am" else guideTitleParts(cell.show.media.item.title).first, Modifier.weight(1f, fill = false), color = ink, fontSize = 13.sp, fontWeight = if (isFocus || onNow) FontWeight.SemiBold else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis) if (runsOn && cellWidth >= GuideTitleMinWidth) Text(" ►", color = ink, fontSize = 9.sp) if (past && cellWidth >= GuideTitleMinWidth + 14.dp) { Spacer(Modifier.width(4.dp)); CatchUpMark() } diff --git a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt index 7d71e07..64825cb 100644 --- a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt +++ b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt @@ -104,8 +104,11 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC var preparedSlot by remember(channel) { mutableStateOf(handedNow?.startMs) } var problem by remember(channel) { mutableStateOf(null) } var retryAttempt by remember(channel) { mutableIntStateOf(0) } + // Kids on a channel during a grown-up takeover: it's asleep, and nothing of it is opened. + val asleep = kidsAsleep(takeoverOf(channel.number, current)) // A new wall-clock slot disposes the old player before opening its successor. - LaunchedEffect(channel, current.startMs, retryAttempt) { + LaunchedEffect(channel, current.startMs, retryAttempt, asleep) { + if (asleep) return@LaunchedEffect if (handed != null && prepared === handed.plan && preparedSlot == current.startMs) return@LaunchedEffect if (loadedNow != null) return@LaunchedEffect prepared = null; preparedSlot = null; problem = null @@ -161,7 +164,8 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC val window = remember { PlaybackWindow() } Dialog(onDismissRequest = { window.onBack() }, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false, securePolicy = SecureFlagPolicy.SecureOn)) { Box(Modifier.fillMaxSize().background(Color.Black)) { - if (plan != null) key(current.startMs) { + if (asleep) { SideEffect { window.onBack = onClose }; KidsAsleep(channel, takeoverOf(channel.number, current)!!) } + else if (plan != null) key(current.startMs) { val announce = remember { announcedChannel[0] != channel.number } SideEffect { announcedChannel[0] = channel.number } EmbyPlaybackDialog(session, plan, onClose, @@ -204,14 +208,16 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC val heldAs = if (early) null else heldBlock val length = if (heldAs != null) BLOCK_IDENT_MS else identLength(channel.number, held.startMs) val startedLate = remember { if (early) 0L else (System.currentTimeMillis() + clockOffsetMs - (held.startMs - length)).coerceAtLeast(0) } + // Entering a takeover: its own ident, once, as it starts (not on Kids, where the channel's asleep). + val entering = remember { takeoverOf(channel.number, held)?.takeIf { it != takeoverOf(channel.number, channel.at(held.startMs - 1)) && !kidsAsleep(it) && !early } } // Now and then a promo for one of tonight's highlights instead, for the same few seconds. - val promo = remember { + val promo = if (entering != null) null else remember { if (early || heldAs != null || channel !is ScheduledChannel || clockOffsetMs != 0L) null else promoFor(channel.number, held.startMs, PromoBoard.items, PromoBoard.shown[channel.number]) { item -> - lockedOut(item.channel) || profileHides(item.channel) || item.driveIn?.let { lockedOut(it) } == true + lockedOut(item.channel) || profileHides(item.channel) || item.driveIn?.let { lockedOut(it) } == true || kidsAsleep(takeoverOf(item.channel, item.entry)) }?.also { PromoBoard.shown[channel.number] = held.startMs } } - if (promo != null) PromoOverlay(session, promo) else IdentOverlay(session, channel, held, length, startedLate, early, heldAs) + if (entering != null) TakeoverIdent(entering) else if (promo != null) PromoOverlay(session, promo) else IdentOverlay(session, channel, held, length, startedLate, early, heldAs) } } if (catchUpFor != null && problem == null && (!catchUpIdentDone || !catchUpPicture)) key("catch-up-ident") { diff --git a/android/app/src/main/java/tv/orbit/classic/Takeovers.kt b/android/app/src/main/java/tv/orbit/classic/Takeovers.kt new file mode 100644 index 0000000..81207be --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/Takeovers.kt @@ -0,0 +1,170 @@ +package tv.orbit.classic + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Text +import java.time.Instant +import java.time.ZoneId +import kotlin.math.sin + +/** + * Channel takeovers (Paul, 6 November 2026, from the approved design): at set hours a channel becomes another, with its own + * name, mark, colour, host and programmes, then hands back. ToonZ Late, Nite Owl (on Junior), Tots Dreamtime and Terra + * Starlight. A takeover's programmes are a block of the channel's own plan, so it shows from the nights planned after it was + * added; committed bookings are never changed. Tots Dreamtime only renames the bedtime hours Tots already has, so it shows at + * once. While one is on, the guide row and the corner mark take its name and colour, and its own ident plays as it starts. + * Kids never sees a grown-up takeover: the channel rests with Luna until morning. + */ +internal enum class TakeoverHost { Blaze, NiteOwl, NightCap, Drift } + +internal class Takeover(val channel: Int, val key: String, val name: String, val colour: Color, val fromHour: Int, val toHour: Int, val titles: List, + val host: TakeoverHost, val line: String, + /** Fine for Kids to watch. */ + val kidsSafe: Boolean, + /** Just a name for hours the channel already fills this way: on by the clock, not by how the night was planned. */ + val byTheClock: Boolean = false) { + fun covers(hour: Int) = if (fromHour <= toHour) hour in fromHour until toHour else hour >= fromHour || hour < toHour + /** When the channel is back for Kids, as words. */ + val backAt get() = "%d%s".format(if (toHour % 12 == 0) 12 else toHour % 12, if (toHour < 12) "am" else "pm") +} + +internal val TOONZ_LATE = Takeover(605, "toonz.late", "ToonZ Late", Color(0xFFFF7AC6), 22, 5, ToonZLateShows, TakeoverHost.Blaze, "Grown-up cartoons till 5am", kidsSafe = false) +internal val NITE_OWL = Takeover(611, "junior.niteowl", "Nite Owl", Color(0xFFFFC23D), 21, 6, NiteOwlShows, TakeoverHost.NiteOwl, "Classic sitcoms till dawn", kidsSafe = false) +internal val TOTS_DREAMTIME = Takeover(TOTS_NUMBER, "tots.dreamtime", "Tots Dreamtime", Color(0xFFC9B8FF), 19, 6, TotsWindDown, TakeoverHost.NightCap, "Bedtime shows with Luna", + kidsSafe = true, byTheClock = true) +internal val TERRA_STARLIGHT = Takeover(201, "terra.starlight", "Terra Starlight", Color(0xFF6EDCAA), 22, 6, TerraStarlightShows, TakeoverHost.Drift, "Space and the night sky till dawn", kidsSafe = true) +internal val Takeovers get() = listOf(TOONZ_LATE, NITE_OWL, TOTS_DREAMTIME, TERRA_STARLIGHT) + +/** A takeover as a block of its channel's day: its own titles, equal turns, its name on the plan. */ +internal fun takeoverPart(t: Takeover) = DayPart(t.fromHour, t.toHour, t.titles, group = t.name, key = t.key, weighted = false) + +/** The takeover a programme on [channel] belongs to, if any: booked in its block, or in its hours for one by the clock. */ +internal fun takeoverOf(channel: Int, show: SampleBroadcast, zone: ZoneId = ZoneId.systemDefault()): Takeover? = Takeovers.firstOrNull { t -> + t.channel == channel && (show.presentation == t.key || (t.byTheClock && t.covers(Instant.ofEpochMilli(show.startMs).atZone(zone).hour))) +} + +/** The same, for a booked entry, reading which block it was planned in from the channel's own day parts. */ +internal fun takeoverOf(channel: Int, entry: ScheduleEntry, zone: ZoneId = ZoneId.systemDefault()): Takeover? { + val parts = SmartChannels.firstOrNull { it.number == channel }?.dayParts.orEmpty() + return takeoverOf(channel, SampleBroadcast(entry.media, entry.startMs, presentationOf(entry, parts, zone)), zone) +} + +/** Hidden from the profile watching: a grown-up takeover, for Kids. */ +internal fun kidsAsleep(takeover: Takeover?) = takeover != null && !takeover.kidsSafe && Profiles.kids + +/** A takeover's mark: Orbit's moon swoosh and its name in its colour, as the channels' own marks are drawn. */ +@Composable +internal fun TakeoverMark(t: Takeover, modifier: Modifier = Modifier, height: Dp = 26.dp, textSize: TextUnit = 16.sp) { + Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Canvas(Modifier.size(height * .8f)) { drawArc(Color.White, 100f, 230f, false, Offset(size.width * .1f, size.height * .1f), Size(size.width * .8f, size.height * .8f), style = Stroke(size.minDimension * .09f)) } + Text(t.name.uppercase(), color = t.colour, fontFamily = Anton, fontSize = textSize, maxLines = 1) + } +} + +/** Nite Owl, to the late-night model sheet (design/arcade/late-night/nite-owl.svg): indigo, ear tufts, half-closed lids, a small beak. */ +internal fun DrawScope.drawNiteOwl(c: Offset, r: Float, shut: Boolean = false) { + val k = r / 36f + fun p(x: Float, y: Float) = Offset(c.x + (x - 50f) * k, c.y + (y - 55f) * k) + val indigo = Color(0xFF4F63E0); val belly = Color(0xFF7F8FF0); val navy = Color(0xFF14213D) + for ((a, b, d) in listOf(Triple(p(22f, 30f), p(28f, 10f), p(40f, 24f)), Triple(p(78f, 30f), p(72f, 10f), p(60f, 24f)))) + drawPath(androidx.compose.ui.graphics.Path().apply { moveTo(a.x, a.y); lineTo(b.x, b.y); lineTo(d.x, d.y); close() }, indigo) + drawOval(indigo, p(16f, 19f), Size(68f * k, 72f * k)) + drawCircle(belly, 20f * k, p(50f, 68f)) + drawOval(Color(0xFF3E50C8), p(15f, 42f), Size(14f * k, 32f * k)); drawOval(Color(0xFF3E50C8), p(71f, 42f), Size(14f * k, 32f * k)) + drawCircle(Color.White.copy(alpha = .22f), 6f * k, p(36f, 34f)) + for (x in listOf(38f, 62f)) { + if (shut) { drawLine(navy, p(x - 8f, 46f), p(x + 8f, 46f), 2.5f * k); continue } + drawCircle(Color.White, 9f * k, p(x, 44f)); drawCircle(navy, 4.5f * k, p(x + 1.5f, 46f)) + drawArc(indigo, 180f, 180f, true, p(x - 9.5f, 34.5f), Size(19f * k, 19f * k)) + } + drawPath(androidx.compose.ui.graphics.Path().apply { val a = p(46f, 51f); val b = p(54f, 51f); val d = p(50f, 58f); moveTo(a.x, a.y); lineTo(b.x, b.y); lineTo(d.x, d.y); close() }, Color(0xFFFFC23D)) +} + +/** Luna in her nightcap: Night Cap, Luna's sign-off look (design/arcade/late-night/night-cap.svg). */ +internal fun DrawScope.drawNightCap(c: Offset, r: Float, shut: Boolean = false) { + drawOrbChaser(c, r, OrbCharacter.Luna, OrbDir.Down, false, false, false, 0f, eyesShut = shut) + val cap = androidx.compose.ui.graphics.Path().apply { + moveTo(c.x - r * .95f, c.y - r * .62f) + quadraticTo(c.x - r * .3f, c.y - r * 1.6f, c.x + r * .7f, c.y - r * 1.3f) + quadraticTo(c.x + r * 1.15f, c.y - r * 1.1f, c.x + r * 1.18f, c.y - r * .62f) + lineTo(c.x + r * .78f, c.y - r * .82f) + quadraticTo(c.x + r * .1f, c.y - r * 1.05f, c.x - r * .6f, c.y - r * .62f); close() + } + drawPath(cap, Color(0xFFC9B8FF)) + drawRoundRect(Color.White, Offset(c.x - r * 1f, c.y - r * .78f), Size(r * 1.85f, r * .3f), androidx.compose.ui.geometry.CornerRadius(r * .15f)) + drawCircle(Color.White, r * .18f, Offset(c.x + r * 1.18f, c.y - r * .58f)) +} + +internal fun DrawScope.drawHost(host: TakeoverHost, c: Offset, r: Float, t: Long, calm: Boolean) { + val shut = !calm && (t % 3_400) < 150 + when (host) { + TakeoverHost.Blaze -> drawOrbChaser(c, r, OrbCharacter.Blaze, OrbDir.Right, false, false, false, t / 140f, eyesShut = shut) + TakeoverHost.NiteOwl -> drawNiteOwl(c, r, shut) + TakeoverHost.NightCap -> drawNightCap(c, r * .9f, shut) + TakeoverHost.Drift -> drawOrbChaser(c, r, OrbCharacter.Drift, OrbDir.Right, false, false, false, t / 500f, eyesShut = shut) + } +} + +@Composable +private fun motionClock(): Pair { + val context = LocalContext.current + val calm = remember { OrbitSettings(context).reduceMotion } + var t by remember { mutableLongStateOf(0L) } + LaunchedEffect(calm) { if (!calm) { val start = withFrameMillis { it }; while (true) withFrameMillis { t = it - start } } } + return t to calm +} + +/** The takeover's own ident, as it starts: its mark large, its line, its host bobbing and blinking. */ +@Composable +internal fun TakeoverIdent(t: Takeover, modifier: Modifier = Modifier) { + val (clock, calm) = motionClock() + val castOn = LocalContext.current.let { context -> remember { OrbitSettings(context).castOn } } + Box(modifier.fillMaxSize().background(Color(0xFF0B1238))) { + StarrySky() + Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) { + TakeoverMark(t, height = 60.dp, textSize = 56.sp) + Text("Starts now: ${t.line.replaceFirstChar { it.lowercase() }}", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.SemiBold) + } + if (castOn) Canvas(Modifier.fillMaxSize()) { + val r = size.minDimension * .09f + val bob = if (calm) 0f else sin(clock / 600.0).toFloat() * r * .12f + drawHost(t.host, Offset(size.width * .2f, size.height * .72f + bob), r, clock, calm) + } + } +} + +/** What Kids sees on a channel during a grown-up takeover: it's asleep, with Luna, and when it's back. */ +@Composable +internal fun KidsAsleep(channel: SampleChannel, t: Takeover, modifier: Modifier = Modifier) { + val (clock, calm) = motionClock() + Box(modifier.fillMaxSize().background(Navy)) { + StarrySky() + Column(Modifier.align(Alignment.Center).width(560.dp).background(Color(0xFF0C2148), RoundedCornerShape(20.dp)).padding(28.dp), + horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) { + Canvas(Modifier.size(140.dp)) { + val bob = if (calm) 0f else sin(clock / 900.0).toFloat() * size.height * .03f + drawNightCap(center + Offset(0f, size.height * .1f + bob), size.minDimension * .32f, !calm && (clock % 3_400) < 150) + } + Text("${ChannelBrands.displayName(channel).removePrefix("Orbit ")} is asleep", color = Color.White, fontFamily = Anton, fontSize = 34.sp) + Text("It's back for Kids at 6am.", color = Muted, fontSize = 17.sp, textAlign = TextAlign.Center) + Text("Up and down still go to Kids' other channels.", color = Muted, fontSize = 14.sp) + } + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/WhatsOnPage.kt b/android/app/src/main/java/tv/orbit/classic/WhatsOnPage.kt index 39c4e88..1a43353 100644 --- a/android/app/src/main/java/tv/orbit/classic/WhatsOnPage.kt +++ b/android/app/src/main/java/tv/orbit/classic/WhatsOnPage.kt @@ -69,7 +69,9 @@ internal fun whatsOnPage(plans: Map, driveIn: ChannelPlan?, no val eveningStart = edition.atTime(EVENING_FROM_HOUR, 0).atZone(zone).toInstant().toEpochMilli() val eveningEnd = edition.plusDays(1).atTime(6, 0).atZone(zone).toInstant().toEpochMilli() val horizon = edition.plusDays(1 + COMING_UP_DAYS).atTime(6, 0).atZone(zone).toInstant().toEpochMilli() - val candidates = plans.flatMap { (channel, plan) -> + // Kids never sees what a grown-up takeover is showing. + val shown = if (Profiles.kids) plans.mapValues { (channel, plan) -> plan.copy(entries = plan.entries.filterNot { kidsAsleep(takeoverOf(channel, it, zone)) }) } else plans + val candidates = shown.flatMap { (channel, plan) -> plan.entries.mapNotNull { entry -> whatsOnRule(channel, entry, zone)?.let { (tag, reason, score) -> WhatsOnItem(channel, entry, tag, reason, score) } } } + driveIn?.let { plan -> driveInBills(plan, zone).filterNot { lockedOut(it) }.map(::driveInItem) }.orEmpty() val live = candidates.filter { it.endMs > nowMs } 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 6498e31..21aeade 100644 --- a/android/app/src/test/java/tv/orbit/classic/PresentationTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/PresentationTest.kt @@ -17,7 +17,8 @@ class PresentationTest { private fun broadcast(key: String, title: String = "Show / S1 E1 / One") = SampleBroadcast(ScheduledMedia(SampleItem(title, "Episode", 30, "", "i", 30 * 60_000L), "c", 30 * 60_000L), 0, key) @Test fun everyApprovedBlockKeyHasItsIdent() { - val keys = SmartChannels.flatMap { it.dayParts }.map { it.key }.filter { it.isNotEmpty() }.toSet() + // 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) assertEquals(emptySet(), keys - BlockIdents.keys) diff --git a/android/app/src/test/java/tv/orbit/classic/TakeoversTest.kt b/android/app/src/test/java/tv/orbit/classic/TakeoversTest.kt new file mode 100644 index 0000000..ffe9593 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/TakeoversTest.kt @@ -0,0 +1,45 @@ +package tv.orbit.classic + +import org.junit.After +import org.junit.Assert.* +import org.junit.Test +import java.time.ZoneId +import java.time.ZonedDateTime + +class TakeoversTest { + private val london = ZoneId.of("Europe/London") + private fun at(h: Int) = ZonedDateTime.of(2026, 11, 6, h, 0, 0, 0, london).toInstant().toEpochMilli() + private fun show(start: Long, presentation: String = "") = SampleBroadcast(ScheduledMedia(SampleItem("A / S1 E1 / B", "Episode", 30, "", "i$start", 1_800_000), "s", 1_800_000), start, presentation) + @After fun reset() { Profiles.current = null } + + @Test fun eachTakeoverIsANightBlockOfItsChannelAndEveryHourIsCoveredOnce() { + for (t in listOf(TOONZ_LATE, NITE_OWL, TERRA_STARLIGHT)) { + val parts = SmartChannels.single { it.number == t.channel }.dayParts.filter { it.days.isEmpty() } + assertTrue(parts.any { it.key == t.key && it.titles == t.titles }) + for (h in 0..23) assertEquals("channel ${t.channel} hour $h", 1, parts.count { it.covers(h) }) + // Its shows are in the channel's pool. + assertTrue(SmartChannels.single { it.number == t.channel }.searches.containsAll(t.titles)) + } + // Junior's pool now goes past the usual 60 series, and keeps them all. + val junior = SmartChannels.single { it.number == 611 } + assertTrue(junior.seriesCount >= junior.searches.size) + } + + @Test fun aTakeoverIsKnownByItsBlockOrForDreamtimeByTheClock() { + assertEquals(TOONZ_LATE, takeoverOf(605, show(at(23), "toonz.late"), london)) + // Booked before the takeover was added: still ToonZ. + assertNull(takeoverOf(605, show(at(23)), london)) + assertNull(takeoverOf(605, show(at(15), ""), london)) + assertEquals(TOTS_DREAMTIME, takeoverOf(TOTS_NUMBER, show(at(20)), london)) + assertNull(takeoverOf(TOTS_NUMBER, show(at(10)), london)) + assertEquals("5am", TOONZ_LATE.backAt); assertEquals("6am", NITE_OWL.backAt) + } + + @Test fun kidsNeverSeesAGrownUpTakeover() { + Profiles.current = DefaultProfiles.first { it.kind == ProfileKind.Kids } + assertTrue(kidsAsleep(TOONZ_LATE)); assertTrue(kidsAsleep(NITE_OWL)) + assertFalse(kidsAsleep(TOTS_DREAMTIME)); assertFalse(kidsAsleep(TERRA_STARLIGHT)); assertFalse(kidsAsleep(null)) + Profiles.current = DefaultProfiles.first { it.kind == ProfileKind.Grown } + assertFalse(kidsAsleep(TOONZ_LATE)) + } +} diff --git a/android/app/src/test/java/tv/orbit/classic/WeightedRotationTest.kt b/android/app/src/test/java/tv/orbit/classic/WeightedRotationTest.kt index b0c4e4f..45e260d 100644 --- a/android/app/src/test/java/tv/orbit/classic/WeightedRotationTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/WeightedRotationTest.kt @@ -211,7 +211,9 @@ class WeightedRotationTest { assertEquals(TIER_SURPRISE, SmartChannels.first { it.number == 203 }.weightOf(show("Dirty Jobs", 100, 60)), 0.0) assertEquals(TIER_CORE, SmartChannels.first { it.number == 603 }.weightOf(show("Mighty Morphin Power Rangers", 100, 60)), 0.0) assertEquals(TIER_CORE, SmartChannels.first { it.number == 202 }.weightOf(show("Cosmos", 100, 60)), 0.0) - assertTrue(SmartChannels.filter { it.number != 614 && it.number != 605 }.all { p -> p.dayParts.all { it.weighted && it.weights.isEmpty() } }) + // A takeover's block takes equal turns (6 November 2026); everything else on these channels is weighted as before. + val takeoverKeys = Takeovers.map { it.key }.toSet() + assertTrue(SmartChannels.filter { it.number != 614 && it.number != 605 }.all { p -> p.dayParts.filter { it.key !in takeoverKeys }.all { it.weighted && it.weights.isEmpty() } }) } @Test fun aShortCatalogueHasItsWeightScaledDownAndSoRepeatsLessOften() {