diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt index 8c31bf2..3962eaa 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -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 diff --git a/android/app/src/main/java/tv/orbit/classic/Promos.kt b/android/app/src/main/java/tv/orbit/classic/Promos.kt new file mode 100644 index 0000000..e9a31d3 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/Promos.kt @@ -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>(emptyList()) + /** When each channel last showed a promo. */ + val shown = mutableMapOf() +} + +/** + * 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, 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()) + } + } +} 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 f74c98e..7d71e07 100644 --- a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt +++ b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt @@ -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") { diff --git a/android/app/src/test/java/tv/orbit/classic/PromosTest.kt b/android/app/src/test/java/tv/orbit/classic/PromosTest.kt new file mode 100644 index 0000000..d6a930a --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/PromosTest.kt @@ -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)) + } +}