Promos between programmes (from 0.108.37)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 2m8s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m22s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-06 00:56:44 +00:00
co-authored by Claude Opus 5.5
parent a7f31b07bf
commit 7ce0e62cc7
4 changed files with 159 additions and 1 deletions
@@ -273,6 +273,14 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
}
ChannelSetup.current = null
}
// The highlights promos are drawn from: What's On's pick and tonight, the Drive-In's bills. Read every five minutes.
LaunchedEffect(scheduledChannels, driveInPlan) {
while (true) {
val page = withContext(Dispatchers.Default) { runCatching { whatsOnPage(scheduledChannels.mapValues { it.value.plan }, driveInPlan, System.currentTimeMillis()) }.getOrNull() }
PromoBoard.items = page?.let { listOfNotNull(it.pick) + it.tonight + it.comingUp }.orEmpty()
delay(5 * 60_000L)
}
}
// Keeps smart channels topped up in the background. A failure changes nothing on air.
LaunchedEffect(session, extraChannels, channel, afterDarkChannel, toonamiChannel) {
val active = session ?: return@LaunchedEffect
@@ -0,0 +1,104 @@
package tv.orbit.classic
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
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.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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.abs
import kotlin.math.sin
/**
* Promos between programmes (Paul, 6 November 2026, from the approved design): now and then a channel's ident gives way to a
* promo for one of Orbit's own highlights: What's On's pick and tonight's list, the Drive-In's bill and events. The same few
* seconds as the ident, so the next programme still starts on time. At most once an hour on a channel, never on Tots or the
* Drive-In itself, never for anything the profile watching can't see. The cast slide in, bob and blink (still with Reduce
* motion), and stay out with the Cast switch off.
*/
internal const val PROMO_GAP_MS = 60 * 60_000L
/** The highlights promos are drawn from, kept fresh by the screen that holds the plans. */
internal object PromoBoard {
var items by mutableStateOf<List<WhatsOnItem>>(emptyList())
/** When each channel last showed a promo. */
val shown = mutableMapOf<Int, Long>()
}
/**
* The promo for the changeover at [atMs] on [channel], or null for the usual ident. Now and then (one changeover in three), at
* most once an hour per channel, for something starting between ten minutes and eight hours on that [hidden] doesn't rule out.
*/
internal fun promoFor(channel: Int, atMs: Long, items: List<WhatsOnItem>, lastShown: Long?, hidden: (WhatsOnItem) -> Boolean): WhatsOnItem? {
if (channel == TOTS_NUMBER || channel == DRIVE_IN_NUMBER) return null
if (lastShown != null && atMs - lastShown < PROMO_GAP_MS) return null
if ((atMs / 60_000) % 3 != 0L) return null
val ahead = items.filter { it.startMs >= atMs + 10 * 60_000 && it.startMs <= atMs + 8 * 60 * 60_000 && !hidden(it) }.distinctBy { it.startMs to it.channel }
if (ahead.isEmpty()) return null
return ahead[((atMs / 60_000) % ahead.size).toInt()]
}
/** What the promo says over the title: Tonight at the Drive-In, Tonight on Paradox, or Tomorrow when it's after the night. */
internal fun promoHeadline(item: WhatsOnItem, nowMs: Long, zone: ZoneId = ZoneId.systemDefault()): String {
val day = Instant.ofEpochMilli(item.startMs).atZone(zone).toLocalDate()
val today = Instant.ofEpochMilli(nowMs).atZone(zone).let { if (it.hour < 6) it.toLocalDate().minusDays(1) else it.toLocalDate() }
val hour = Instant.ofEpochMilli(item.startMs).atZone(zone).hour
val evening = day == today || (day == today.plusDays(1) && hour < 6)
val whenWord = if (evening) (if (hour in 6..16 && day == today) "LATER" else "TONIGHT") else "TOMORROW"
val where = if (item.channel == DRIVE_IN_NUMBER) "AT THE DRIVE-IN" else "ON " + (ChannelBrands.forNumber(item.channel)?.displayName ?: "ORBIT").removePrefix("Orbit ").uppercase()
return "$whenWord $where"
}
@Composable
internal fun PromoOverlay(session: EmbySampleSession, item: WhatsOnItem) {
val context = LocalContext.current
val settings = remember { OrbitSettings(context) }
val calm = remember { settings.reduceMotion }
val castOn = remember { settings.castOn }
var t by remember { mutableLongStateOf(if (calm) 5_000L else 0L) }
LaunchedEffect(calm) { if (!calm) { val start = withFrameMillis { it }; while (true) withFrameMillis { t = it - start } } }
val picture = rememberFilmPicture(session, item.entry.media.item.id)
val now = remember { System.currentTimeMillis() }
Box(Modifier.fillMaxSize().background(Navy)) {
StarrySky()
Column(Modifier.align(Alignment.Center).padding(top = 10.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
ChannelBrands.forNumber(item.channel)?.let { Image(painterResource(it.logo), it.displayName, Modifier.width(150.dp).height(46.dp)) }
Text(promoHeadline(item, now), color = Yellow, fontFamily = Anton, fontSize = 34.sp)
Box(Modifier.width(500.dp).height(190.dp).clip(RoundedCornerShape(12.dp)).background(Panel)) {
if (picture != null) Image(picture.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = .3f)))
Text(item.title.uppercase(), Modifier.align(Alignment.BottomStart).padding(14.dp), color = Color.White, fontFamily = Anton, fontSize = 26.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
item.detail?.let { Text(it, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) }
Text("From ${clock(item.startMs)}", color = Muted, fontSize = 16.sp)
}
// Ollie rolls in from the left and Bumble buzzes in from the right, then they bob and blink.
if (castOn) Canvas(Modifier.fillMaxSize()) {
val arrive = if (calm) 1f else (t / 700f).coerceIn(0f, 1f)
val eased = 1 - (1 - arrive) * (1 - arrive)
val r = size.minDimension * .09f
val bob = if (calm) 0f else sin(t / 600.0).toFloat() * r * .12f
val shut = !calm && (t % 3_200) < 150
val ollie = Offset(size.width * (.16f - .3f * (1 - eased)), size.height * .55f + bob)
drawOrbMascot(ollie, r, blink = shut)
val bee = Offset(size.width * (.84f + .3f * (1 - eased)), size.height * .5f - bob + if (calm) 0f else abs(sin(t / 220.0)).toFloat() * r * .08f)
drawOrbBee(bee, r * .8f, if (calm) 0f else sin(t / 40.0).toFloat())
}
}
}
@@ -204,7 +204,14 @@ 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) }
IdentOverlay(session, channel, held, length, startedLate, early, heldAs)
// Now and then a promo for one of tonight's highlights instead, for the same few seconds.
val promo = 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
}?.also { PromoBoard.shown[channel.number] = held.startMs }
}
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,39 @@
package tv.orbit.classic
import org.junit.Assert.*
import org.junit.Test
import java.time.ZoneId
import java.time.ZonedDateTime
class PromosTest {
private val london = ZoneId.of("Europe/London")
private val minute = 60_000L
private fun at(d: Int, h: Int, m: Int = 0) = ZonedDateTime.of(2026, 11, d, h, m, 0, 0, london).toInstant().toEpochMilli()
private fun item(channel: Int, start: Long, title: String = "Film") =
WhatsOnItem(channel, ScheduleEntry("s$start", EpisodeMark(1, 1), ScheduledMedia(SampleItem(title, "Movie", 90, "", "i$start", 90 * minute), "src", 90 * minute), start), "TONIGHT'S PICK", "", 90)
// A changeover on a minute divisible by three, so it's one of the "now and then".
private val changeover = at(6, 18, 0).let { it - Math.floorMod(it / minute, 3L) * minute }
@Test fun nowAndThenAtMostHourlyAndForSomethingAhead() {
val items = listOf(item(302, changeover + 2 * 60 * minute), item(DRIVE_IN_NUMBER, changeover + 60 * minute))
assertNotNull(promoFor(106, changeover, items, null) { false })
// Not twice within the hour, and not on the other changeovers.
assertNull(promoFor(106, changeover + 30 * minute, items, changeover) { false })
assertNull(promoFor(106, changeover + minute, items, null) { false })
// Never on Tots or the Drive-In itself, never for something starting within ten minutes or already gone.
assertNull(promoFor(TOTS_NUMBER, changeover, items, null) { false })
assertNull(promoFor(DRIVE_IN_NUMBER, changeover, items, null) { false })
assertNull(promoFor(106, changeover, listOf(item(302, changeover + 5 * minute)), null) { false })
// Nothing the profile watching can't see.
assertNull(promoFor(106, changeover, items, null) { true })
}
@Test fun theHeadlineSaysWhenAndWhere() {
val now = at(6, 18)
assertEquals("TONIGHT AT THE DRIVE-IN", promoHeadline(item(DRIVE_IN_NUMBER, at(6, 20)), now, london))
assertEquals("TONIGHT ON ACTION", promoHeadline(item(302, at(6, 21)), now, london))
assertEquals("TONIGHT ON ACTION", promoHeadline(item(302, at(7, 1)), now, london))
assertEquals("TOMORROW ON ACTION", promoHeadline(item(302, at(7, 20)), now, london))
assertEquals("LATER ON ACTION", promoHeadline(item(302, at(6, 15)), at(6, 12), london))
}
}