Promos between programmes (from 0.108.37)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user