Channel takeovers (from 0.108.39)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -72,7 +72,7 @@ internal class CatchUpShelf(val title: String, val entries: List<CatchUpEntry>)
|
||||
*/
|
||||
internal fun catchUpShelves(channels: List<SampleChannel>, nowMs: Long, zone: ZoneId = ZoneId.systemDefault()): List<CatchUpShelf> {
|
||||
val all = channels.filterIsInstance<ScheduledChannel>().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()
|
||||
|
||||
@@ -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<String, Double> 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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -162,7 +162,7 @@ internal fun GuideGrid(channels: List<SampleChannel>, 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<SampleChannel>, 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<SampleChannel>, 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() }
|
||||
|
||||
@@ -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<String?>(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") {
|
||||
|
||||
@@ -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<String>,
|
||||
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<Long, Boolean> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,9 @@ internal fun whatsOnPage(plans: Map<Int, ChannelPlan>, 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 }
|
||||
|
||||
@@ -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<String>(), keys - BlockIdents.keys)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user