Build Orbit Drive-In with real events and three scenes (0.67.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m51s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m28s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-02 12:05:43 +00:00
co-authored by Claude Opus 5.5
parent dfb729ca86
commit 089b934e48
24 changed files with 563 additions and 22 deletions
+16
View File
@@ -0,0 +1,16 @@
# Orbit TV 0.67.0: Orbit Drive-In
The Drive-In now has real events from Paul's films, on a full-screen destination with three scenes that take turns, one per visit: his theatre, his drive-in (feature artwork on the big screen) and the CRT.
- Sunday to Thursday: Orbit Marquee at 20:00, then The Late Show at 23:00, or 15 to 30 minutes after a long film.
- Friday: Orbit Double Bill at 20:00, then The Late Show. Saturday: Orbit Triple Bill at 19:00.
- Double and triple bills are hand-picked and rotate by week. One with a film missing, recently shown or on elsewhere is passed over; Friday or Saturday falls back to a Marquee if none fits.
- Booked a week ahead, once, in schedule-160.json, using film pools already on the box, so it makes no extra provider requests. Other channels now avoid the Drive-In's films when they top up, and bills avoid what they've already committed.
- OK on a showing bill tunes it live. OK on a later bill shows its ticket. Between shows, the player closes back to the lot.
- The home card shows the real next bill.
Checked: build, lint and 266 unit tests pass, including 12 new ones. The tests caught a late show being merged into the bill before it, fixed by always leaving a break.
Not checked: how it looks on the TV. Screenshots are blocked on the box, so text positions on the marquee boards and the artwork on the big screen and CRT were placed by measurement and need Paul's eye. Tuning a bill live and returning to the lot between shows also haven't been run on the box.
Not built: reminders. A ticket doesn't yet alert you when the show starts. The app is 19.6 MB, up from 19.1 MB. Catalogue unchanged.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 67
versionName = "0.66.0-full-lineup"
versionCode = 68
versionName = "0.67.0-drive-in"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -26,6 +26,7 @@ internal object ChannelBrands {
136 to ChannelBranding("orbit-premiere", "Orbit Premiere", R.drawable.orbit_premiere_epg, R.drawable.orbit_one_background, R.drawable.orbit_premiere_bug, ChannelCategory.Movies),
144 to ChannelBranding("orbit-odyssey", "Orbit Odyssey", R.drawable.orbit_odyssey_epg, R.drawable.orbit_one_background, R.drawable.orbit_odyssey_bug, ChannelCategory.Documentaries),
150 to ChannelBranding("orbit-ring-x", "Orbit Ring X", R.drawable.orbit_ring_x_epg, R.drawable.orbit_one_background, R.drawable.orbit_ring_x_bug, ChannelCategory.Sport),
DRIVE_IN_NUMBER to ChannelBranding("orbit-drive-in", "Orbit Drive-In", R.drawable.orbit_drive_in_epg, R.drawable.orbit_one_background, R.drawable.orbit_drive_in_bug, ChannelCategory.Movies),
121 to ChannelBranding("heroes", "Orbit Heroes", R.drawable.heroes_epg, R.drawable.heroes_background, R.drawable.heroes_bug, ChannelCategory.Cartoons),
122 to ChannelBranding("toonz", "Orbit ToonZ", R.drawable.toonz_epg, R.drawable.toonz_background, R.drawable.toonz_bug, ChannelCategory.Cartoons),
123 to ChannelBranding("rewind", "Orbit Rewind", R.drawable.rewind_epg, R.drawable.rewind_background, R.drawable.rewind_bug, ChannelCategory.Cartoons),
@@ -26,6 +26,20 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
store.saveProgress(userId, number, ScheduleStore.SavedProgress(enabled, progress?.aired ?: ScheduleCursor()))
}
/** Books the Drive-In's evenings from the film pools already on the box, so it asks the provider for nothing. */
fun topUpDriveIn(userId: String): ChannelPlan? {
val now = clock()
val existing = store.loadPlan(userId, DRIVE_IN_NUMBER)
if (driveInBookedAhead(existing, now)) return existing
val failed = store.loadFailed(userId)
fun films(numbers: List<Int>) = numbers.flatMap { number -> withoutFailed(store.loadPool(userId, number)?.series.orEmpty().filter { it.isFilm }, failed) }.distinctBy { seriesKey(it.title) }
val avoid = clashesWith(SmartChannels.mapNotNull { store.loadPlan(userId, it.number) })
val plan = planDriveIn(existing, films(DriveInMarqueeChannels), films(DriveInLateChannels), films(DriveInBillChannels), now, avoid = avoid) ?: return existing
if (plan !== existing) store.savePlan(userId, DRIVE_IN_NUMBER, plan)
Log.i("OrbitSchedule", "channel=$DRIVE_IN_NUMBER programmes=${plan.entries.size}")
return plan
}
/** A file that wouldn't play. It stays in what's already planned, and is left out from here on. */
fun markFailed(userId: String, itemId: String) {
store.saveFailed(userId, store.loadFailed(userId) + itemId)
@@ -73,7 +87,7 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
val progress = store.loadProgress(userId, number)
val base = progress?.aired ?: ScheduleCursor()
// Other channels' plans are only read if this one is actually about to grow.
val others by lazy { clashesWith(SmartChannels.filter { it.number != number }.mapNotNull { store.loadPlan(userId, it.number) }) }
val others by lazy { clashesWith((SmartChannels.map { it.number }.filter { it != number } + DRIVE_IN_NUMBER).mapNotNull { store.loadPlan(userId, it) }) }
val plan = topUpPlan(existing, base, pool?.series?.let { withoutFailed(it, store.loadFailed(userId)) }, now, programming.filmRestMs, programming.finishTogether, programming.dayParts,
avoid = { show, startMs, endMs -> others(show, startMs, endMs) }) ?: throw EmbyCheckException("${programming.name} has no episodes to schedule.")
if (plan !== existing) store.savePlan(userId, number, plan)
@@ -0,0 +1,365 @@
package tv.orbit.classic
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Path
import android.graphics.RectF
import android.view.KeyEvent
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.focusable
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.io.File
import java.time.DayOfWeek
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.TextStyle as DateTextStyle
/** Not a channel in the guide: one linear evening's bill, with nothing on between shows. */
internal const val DRIVE_IN_NUMBER = 160
internal const val DRIVE_IN_SCENES = 3
private const val DRIVE_IN_REST_MS = 14 * 24 * 60 * 60_000L
internal val DriveInMarqueeChannels = listOf(136, 132, 134, 131)
internal val DriveInLateChannels = listOf(133, 102)
internal val DriveInBillChannels = listOf(136, 131, 132, 133, 134, 135, 126, 127, 114, 115, 102)
/** Hand-picked, so a bill always belongs together. One whose films aren't all in the library is skipped. */
private val DoubleBills = listOf(listOf("Alien", "Aliens"), listOf("The Terminator", "Terminator 2: Judgment Day"), listOf("Predator", "RoboCop"), listOf("Evil Dead II", "Army of Darkness"),
listOf("Bill & Ted's Excellent Adventure", "Bill & Ted's Bogus Journey"), listOf("Ghostbusters", "Ghostbusters II"), listOf("Gremlins", "Gremlins 2: The New Batch"), listOf("The Karate Kid", "The Karate Kid Part II"),
listOf("John Wick", "John Wick: Chapter 2"), listOf("Kill Bill: Vol. 1", "Kill Bill: Vol. 2"), listOf("Spider-Man", "Spider-Man 2"), listOf("Home Alone", "Home Alone 2: Lost in New York"),
listOf("Lethal Weapon", "Lethal Weapon 2"), listOf("Trainspotting", "T2 Trainspotting"), listOf("28 Days Later", "28 Weeks Later"), listOf("The Incredibles", "Incredibles 2"))
private val TripleBills = listOf(listOf("Back to the Future", "Back to the Future Part II", "Back to the Future Part III"), listOf("Star Wars", "The Empire Strikes Back", "Return of the Jedi"),
listOf("Raiders of the Lost Ark", "Indiana Jones and the Temple of Doom", "Indiana Jones and the Last Crusade"), listOf("Shaun of the Dead", "Hot Fuzz", "The World's End"),
listOf("The Bourne Identity", "The Bourne Supremacy", "The Bourne Ultimatum"), listOf("Die Hard", "Die Hard 2", "Die Hard: With a Vengeance"), listOf("Batman Begins", "The Dark Knight", "The Dark Knight Rises"),
listOf("Toy Story", "Toy Story 2", "Toy Story 3"), listOf("Mad Max", "Mad Max 2", "Mad Max: Fury Road"), listOf("The Hangover", "The Hangover Part II", "The Hangover Part III"),
listOf("The Lord of the Rings: The Fellowship of the Ring", "The Lord of the Rings: The Two Towers", "The Lord of the Rings: The Return of the King"))
internal enum class DriveInKind(val label: String) { Marquee("Orbit Marquee"), Double("Orbit Double Bill"), Triple("Orbit Triple Bill"), LateShow("The Late Show") }
/** One night out: a film, or films running straight into each other. */
internal class DriveInBill(val kind: DriveInKind, val entries: List<ScheduleEntry>) {
val startMs get() = entries.first().startMs
val endMs get() = entries.last().endMs
val films get() = entries.map { it.media.item.title }
val title get() = films.joinToString(" + ")
}
/** An evening runs past midnight, so the small hours belong to the night before. */
private fun eveningOf(timeMs: Long, zone: ZoneId): LocalDate {
val local = Instant.ofEpochMilli(timeMs).atZone(zone)
return if (local.hour < 6) local.toLocalDate().minusDays(1) else local.toLocalDate()
}
internal fun driveInBookedAhead(plan: ChannelPlan?, nowMs: Long, zone: ZoneId = ZoneId.systemDefault()): Boolean {
val last = plan?.entries?.lastOrNull() ?: return false
return !eveningOf(last.startMs, zone).isBefore(eveningOf(nowMs + SCHEDULE_AHEAD_MS, zone))
}
private fun filmEntry(film: PoolSeries, startMs: Long): ScheduleEntry {
val copy = film.episodes.first()
return ScheduleEntry(film.id, copy.mark, ScheduledMedia(SampleItem(film.title, "Movie", copy.durationMs / 60_000, "", copy.id, copy.durationMs), copy.sourceId, copy.durationMs), startMs)
}
/**
* Books each evening out to a week ahead, once, so a bill never changes after it's shown.
* Friday is a double bill, Saturday a triple, and other nights a big film. A late show follows, except after a triple.
*/
internal fun planDriveIn(existing: ChannelPlan?, marquee: List<PoolSeries>, late: List<PoolSeries>, bills: List<PoolSeries>, nowMs: Long,
zone: ZoneId = ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }): ChannelPlan? {
val kept = existing?.let { plan -> plan.copy(entries = plan.entries.dropWhile { it.endMs < nowMs - SCHEDULE_KEEP_PAST_MS }) }
if (driveInBookedAhead(kept, nowMs, zone)) return kept
if (marquee.isEmpty()) return kept
val byTitle = bills.associateBy { seriesKey(it.title) }
val shown = kept?.tail?.filmShown.orEmpty().toMutableMap()
val entries = kept?.entries.orEmpty().toMutableList()
var date = kept?.entries?.lastOrNull()?.let { eveningOf(it.startMs, zone).plusDays(1) } ?: eveningOf(nowMs, zone)
val lastDate = eveningOf(nowMs + SCHEDULE_AHEAD_MS, zone)
fun book(film: PoolSeries, startMs: Long): Long = filmEntry(film, startMs).let { entry -> entries += entry; shown[film.id] = startMs; entry.endMs }
/** A hand-picked bill, turn and turn about by week, whose films are all here, rested and not on elsewhere. */
fun billFor(choices: List<List<String>>, startMs: Long, week: Long): List<PoolSeries>? = choices.indices.asSequence().map { choices[Math.floorMod(week + it, choices.size.toLong()).toInt()] }.mapNotNull { names ->
val films = names.map { byTitle[seriesKey(it)] ?: return@mapNotNull null }
var at = startMs
films.takeIf { it.all { film ->
val end = at + film.episodes.first().durationMs
(shown[film.id]?.let { seen -> startMs - seen >= DRIVE_IN_REST_MS } ?: true) && !avoid(film, at, end).also { at = end }
} }
}.firstOrNull()
while (!date.isAfter(lastDate)) {
val week = Math.floorDiv(date.toEpochDay(), 7L)
fun at(hour: Int, minute: Int = 0) = date.atTime(hour, minute).atZone(zone).toInstant().toEpochMilli()
val triple = if (date.dayOfWeek == DayOfWeek.SATURDAY) billFor(TripleBills, at(19), week) else null
val double = if (date.dayOfWeek == DayOfWeek.FRIDAY) billFor(DoubleBills, at(20), week) else null
var end = when {
triple != null -> triple.fold(at(19)) { start, film -> book(film, start) }
double != null -> double.fold(at(20)) { start, film -> book(film, start) }
else -> book(nextFilm(marquee, shown, at(20), DRIVE_IN_REST_MS, avoid), at(20))
}
if (triple == null && late.isNotEmpty()) {
val quarter = 15 * 60_000L
// Always a break first, which is also how a late show is told apart from the bill before it.
val start = maxOf(at(23), (end + 2 * quarter - 1) / quarter * quarter)
// Too late to be a late show by then: the night ends with the main feature.
if (start < at(23) + 4 * 60 * 60_000L) end = book(nextFilm(late, shown, start, DRIVE_IN_REST_MS, avoid), start)
}
date = date.plusDays(1)
}
return ChannelPlan(entries, ScheduleCursor(null, emptyMap(), shown))
}
internal fun driveInBills(plan: ChannelPlan, zone: ZoneId = ZoneId.systemDefault()): List<DriveInBill> {
val runs = mutableListOf<MutableList<ScheduleEntry>>()
for (entry in plan.entries) {
val run = runs.lastOrNull()
if (run != null && run.last().endMs == entry.startMs) run += entry else runs += mutableListOf(entry)
}
return runs.map { run ->
val hour = Instant.ofEpochMilli(run.first().startMs).atZone(zone).hour
DriveInBill(when {
run.size >= 3 -> DriveInKind.Triple
run.size == 2 -> DriveInKind.Double
hour in 6..21 -> DriveInKind.Marquee
else -> DriveInKind.LateShow
}, run)
}
}
/** The next of each kind, for the list on screen. */
internal fun driveInLineup(bills: List<DriveInBill>, nowMs: Long): List<DriveInBill> =
DriveInKind.values().mapNotNull { kind -> bills.firstOrNull { it.kind == kind && it.endMs > nowMs } }
internal fun driveInAiring(plan: ChannelPlan, nowMs: Long) = plan.entries.any { it.startMs <= nowMs && nowMs < it.endMs }
internal fun driveInDay(bill: DriveInBill, nowMs: Long, zone: ZoneId = ZoneId.systemDefault()): String {
if (bill.startMs <= nowMs) return "Now showing"
val date = eveningOf(bill.startMs, zone)
val today = eveningOf(nowMs, zone)
return when (date) {
today -> "Tonight"
today.plusDays(1) -> "Tomorrow"
else -> date.dayOfWeek.getDisplayName(DateTextStyle.FULL, java.util.Locale.UK)
}
}
internal fun driveInWhen(bill: DriveInBill, nowMs: Long): String =
if (bill.startMs <= nowMs) "now showing" else "${driveInDay(bill, nowMs).lowercase(java.util.Locale.UK)} at ${clock(bill.startMs)}"
/** The bill as something the player can tune to. Outside the bill's times the screen closes the player. */
internal fun driveInChannel(plan: ChannelPlan): ScheduledChannel? {
val films = plan.entries.map { it.media }.distinctBy { it.item.id }.take(15)
if (films.size < 2) return null
return ScheduledChannel(SampleChannel(films, "Orbit Drive-In", DRIVE_IN_NUMBER), plan)
}
private val Marquee = FontFamily(Font(R.font.anton_regular))
private val BoardInk = Color(0xFF2A1238)
private val BoardPink = Color(0xFFC2358F)
private val Gold = Color(0xFFF2C14E)
@Composable
private fun rememberFilmPicture(session: EmbySampleSession, itemId: String?): Bitmap? {
val context = LocalContext.current
val picture by produceState<Bitmap?>(null, itemId) {
value = null
if (itemId != null) value = withContext(Dispatchers.IO) {
runCatching {
loadArtwork(ArtworkCache(File(context.cacheDir, "artwork")), itemId, null, session::fetchArtwork)?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
}.getOrNull()
}
}
return picture
}
/** Tonight's bill in the board's own letters. The board stays the same size, so long titles get smaller. */
@Composable
private fun MarqueeBoard(bill: DriveInBill?, nowMs: Long, x: Dp, y: Dp, width: Dp, height: Dp, big: Float) {
Box(Modifier.offset(x, y).size(width, height), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy((big * .1f).dp)) {
val head = bill?.let { driveInDay(it, nowMs).uppercase(java.util.Locale.UK) } ?: "COMING SOON"
Text(if (bill != null) "$head AT THE DRIVE-IN" else head, color = BoardInk, fontFamily = Marquee, fontSize = big.sp, maxLines = 1, softWrap = false)
if (bill != null) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Box(Modifier.size((big * 1.4f).dp, 2.dp).background(BoardPink))
Text(bill.kind.label.uppercase(java.util.Locale.UK), color = BoardInk, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = (big * .62f).sp, letterSpacing = 1.sp, maxLines = 1, softWrap = false)
Box(Modifier.size((big * 1.4f).dp, 2.dp).background(BoardPink))
}
val films = bill.films.joinToString(" • ").uppercase(java.util.Locale.UK)
val fit = when { films.length > 60 -> .36f; films.length > 42 -> .46f; else -> .58f }
Text(films, Modifier.fillMaxWidth(), color = BoardInk, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = (big * fit).sp, textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
@Composable
private fun EventTile(bill: DriveInBill, nowMs: Long, selected: Boolean, modifier: Modifier) {
val corner = RoundedCornerShape(5.dp)
val ink = if (selected) Navy else Color.White
Row((if (selected) modifier.background(Yellow, corner) else modifier.background(Color(0xFF0E2147), corner).border(1.dp, Color(0xFFC98A1E), corner)).padding(horizontal = 10.dp),
verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(driveInDay(bill, nowMs).uppercase(java.util.Locale.UK), color = if (selected) Navy else Gold, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, letterSpacing = 1.sp, maxLines = 1)
Text(bill.kind.label, color = ink, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Text(clock(bill.startMs), color = ink, fontFamily = Barlow, fontSize = 16.sp, maxLines = 1)
}
}
/** Over the lot's big screen, which leans away from the viewer. Corners are in the 1920-wide scene. */
@Composable
private fun PictureOnBigScreen(picture: Bitmap?) {
if (picture == null) return
Canvas(Modifier.fillMaxSize()) {
val s = size.width / 1920f
val to = floatArrayOf(1321f, 340f, 1860f, 262f, 1866f, 591f, 1321f, 597f).map { it * s }.toFloatArray()
val from = floatArrayOf(0f, 0f, picture.width.toFloat(), 0f, picture.width.toFloat(), picture.height.toFloat(), 0f, picture.height.toFloat())
val matrix = Matrix().apply { setPolyToPoly(from, 0, to, 0, 4) }
drawIntoCanvas { it.nativeCanvas.drawBitmap(picture, matrix, Paint(Paint.FILTER_BITMAP_FLAG)) }
}
}
/** On the CRT's curved glass, cropped to its old shape, with faint scan lines. */
@Composable
private fun PictureOnTube(picture: Bitmap?) {
Canvas(Modifier.fillMaxSize()) {
val s = size.width / 1920f
val glass = Path().apply {
moveTo(160 * s, 152 * s); cubicTo(340 * s, 132 * s, 680 * s, 132 * s, 860 * s, 152 * s); cubicTo(884 * s, 300 * s, 884 * s, 640 * s, 860 * s, 788 * s)
cubicTo(680 * s, 808 * s, 340 * s, 808 * s, 160 * s, 788 * s); cubicTo(136 * s, 640 * s, 136 * s, 300 * s, 160 * s, 152 * s); close()
}
val area = RectF(136 * s, 136 * s, 884 * s, 804 * s)
drawIntoCanvas { canvas ->
val native = canvas.nativeCanvas
native.save(); native.clipPath(glass)
if (picture != null) {
val scale = maxOf(area.width() / picture.width, area.height() / picture.height)
val matrix = Matrix().apply { postTranslate(-picture.width / 2f, -picture.height / 2f); postScale(scale, scale); postTranslate(area.centerX(), area.centerY()) }
native.drawBitmap(picture, matrix, Paint(Paint.FILTER_BITMAP_FLAG))
}
val line = Paint().apply { color = android.graphics.Color.argb(40, 0, 0, 0); strokeWidth = 2 * s }
var y = area.top
while (y < area.bottom) { native.drawLine(area.left, y, area.right, y, line); y += 6 * s }
native.restore()
}
}
}
@Composable
private fun DriveInTicket(bill: DriveInBill, nowMs: Long, onClose: () -> Unit) {
BackHandler(onBack = onClose)
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = .6f)), contentAlignment = Alignment.Center) {
Row(Modifier.width(620.dp).height(250.dp).background(Color(0xFF0E2147)).border(3.dp, Gold).padding(20.dp)) {
Column(Modifier.weight(1f).fillMaxHeight()) {
Text("ORBIT DRIVE-IN", color = Gold, fontFamily = Marquee, fontSize = 26.sp, maxLines = 1)
Text(bill.kind.label, color = Color.White, fontFamily = Barlow, fontSize = 15.sp)
Spacer(Modifier.height(10.dp))
Text(bill.title, color = Color.White, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 26.sp, maxLines = 3, overflow = TextOverflow.Ellipsis)
Spacer(Modifier.weight(1f))
Text("Doors open ${driveInWhen(bill, nowMs)}", color = Gold, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, maxLines = 1)
Text("Back Return to the lot", color = Muted, fontFamily = Barlow, fontSize = 12.sp)
}
Box(Modifier.padding(horizontal = 16.dp).width(1.dp).fillMaxHeight().background(Gold.copy(alpha = .6f)))
Column(Modifier.width(84.dp).fillMaxHeight(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Text("ADMIT", color = Gold, fontFamily = Barlow, fontSize = 18.sp)
Text("01", color = Color.White, fontFamily = Barlow, fontSize = 36.sp)
Text("FRONT ROW", color = Gold, fontFamily = Barlow, fontSize = 10.sp)
}
}
}
}
/** Three scenes take turns, a new one each visit: the theatre, the lot and the old set. */
@Composable
internal fun DriveInScreen(session: EmbySampleSession, plan: ChannelPlan?, visit: Int, modifier: Modifier, active: Boolean, onWatch: () -> Unit) {
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) { while (true) { delay(1000); now = System.currentTimeMillis() } }
val minute = now / 60_000
val lineup = remember(plan, minute) { plan?.let { driveInLineup(driveInBills(it), now) }.orEmpty() }
var chosenKind by remember { mutableStateOf<DriveInKind?>(null) }
val soonest = lineup.minByOrNull { it.startMs }
val selected = lineup.firstOrNull { it.kind == chosenKind } ?: soonest
val index = lineup.indexOf(selected)
var ticket by remember { mutableStateOf<DriveInBill?>(null) }
val focus = remember { FocusRequester() }
LaunchedEffect(active, ticket) { if (active && ticket == null) focus.requestFocus() }
val scene = Math.floorMod(visit, DRIVE_IN_SCENES)
Box(modifier.fillMaxSize().background(Navy).focusRequester(focus).focusable().onPreviewKeyEvent { event ->
val native = event.nativeKeyEvent
if (native.action != KeyEvent.ACTION_DOWN || lineup.isEmpty()) return@onPreviewKeyEvent false
when (native.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_UP -> { chosenKind = lineup[Math.floorMod(index - 1, lineup.size)].kind; true }
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_DOWN -> { chosenKind = lineup[Math.floorMod(index + 1, lineup.size)].kind; true }
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> {
if (native.repeatCount == 0) selected?.let { if (it.startMs <= now) onWatch() else ticket = it }
true
}
else -> false
}
}) {
val backdrop = when (scene) { 0 -> R.drawable.drive_in_theatre; 1 -> R.drawable.drive_in_lot; else -> R.drawable.drive_in_crt }
Image(painterResource(backdrop), null, Modifier.fillMaxSize(), contentScale = ContentScale.FillBounds)
// Only the two scenes with a screen need the film's picture.
val picture = rememberFilmPicture(session, selected?.entries?.first()?.media?.item?.id.takeIf { scene != 0 })
when (scene) {
0 -> MarqueeBoard(selected, now, 270.dp, 192.dp, 430.dp, 92.dp, 31f)
1 -> {
PictureOnBigScreen(picture)
MarqueeBoard(selected, now, 225.dp, 186.dp, 300.dp, 76.dp, 25f)
}
else -> {
PictureOnTube(picture)
if (selected != null) Column(Modifier.offset(68.dp, 318.dp).size(374.dp, 84.dp).background(Color(0xCC03081A)).padding(horizontal = 14.dp, vertical = 8.dp)) {
Text("${driveInDay(selected, now).uppercase(java.util.Locale.UK)} • ${selected.kind.label.uppercase(java.util.Locale.UK)}", color = Gold, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, letterSpacing = 1.sp, maxLines = 1)
Text(selected.title.uppercase(java.util.Locale.UK), color = Color.White, fontFamily = Marquee, fontSize = if (selected.title.length > 40) 15.sp else 24.sp, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
}
if (scene == 2) {
Column(Modifier.offset(607.dp, 200.dp).width(330.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
lineup.forEach { EventTile(it, now, it === selected, Modifier.fillMaxWidth().height(50.dp)) }
if (lineup.isEmpty()) Text("Tonight's bill is on its way.", color = Color.White, fontFamily = Barlow, fontSize = 16.sp)
}
} else {
Box(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(100.dp).background(Brush.verticalGradient(listOf(Color.Transparent, Color(0xEB03081A)))))
Row(Modifier.align(Alignment.BottomCenter).padding(start = 20.dp, end = 20.dp, bottom = 26.dp).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
lineup.forEach { EventTile(it, now, it === selected, Modifier.weight(1f).height(42.dp)) }
if (lineup.isEmpty()) Text("Tonight's bill is on its way.", color = Color.White, fontFamily = Barlow, fontSize = 16.sp)
}
}
val hint = if (selected != null && selected.startMs <= now) "OK Watch" else "OK Get your ticket"
Text("$hint ◄► Choose an event Back Leave", Modifier.align(Alignment.BottomStart).padding(start = 20.dp, bottom = 6.dp), color = Color(0xFFD5DCEA), fontFamily = Barlow, fontSize = 11.sp)
ticket?.let { DriveInTicket(it, now, onClose = { ticket = null }) }
}
}
@@ -66,7 +66,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI
connection.connectTimeout = 10_000
connection.readTimeout = request.readTimeoutMs ?: if (request.path == "/Sessions/Logout") 5_000 else 15_000
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.66.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.67.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -52,7 +52,7 @@ private const val RESTORE_QUIET_TRIES = 4
@Composable
internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolean,
mainGuide: Boolean, mainHome: Boolean = false, onChannelAvailable: (Boolean) -> Unit, onRestoreChecked: () -> Unit = {}, onGuide: () -> Unit,
mainGuide: Boolean, mainHome: Boolean = false, mainDriveIn: Boolean = false, onChannelAvailable: (Boolean) -> Unit, onRestoreChecked: () -> Unit = {}, onGuide: () -> Unit,
onHome: () -> Unit = {}, onDriveIn: () -> Unit = {}, onBack: () -> Unit) {
val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext
val savedStore = remember { RememberedConnectionStore.get(appContext) }
@@ -92,6 +92,10 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
var selectedNumber by remember { mutableIntStateOf(101) }
var channel by remember { mutableStateOf<SampleChannel?>(null) }
var watchingChannel by remember { mutableStateOf(false) }
var watchingDriveIn by remember { mutableStateOf(false) }
var driveInPlan by remember { mutableStateOf<ChannelPlan?>(null) }
// Each visit opens on the next of the three scenes.
var driveInVisits by remember { mutableIntStateOf((System.currentTimeMillis() / 60_000 % DRIVE_IN_SCENES).toInt()) }
var guideCategory by remember { mutableStateOf(ChannelCategory.All) }
val liveHandoff = remember { LiveHandoff() }
val scheduleStore = remember { ScheduleStore(java.io.File(appContext.noBackupFilesDir, "schedule")) }
@@ -258,6 +262,9 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
} catch (cancelled: CancellationException) { throw cancelled }
catch (_: Exception) { /* Tried again next hour. */ }
}
try { driveInPlan = withContext(Dispatchers.IO) { scheduler.topUpDriveIn(active.accountId) } }
catch (cancelled: CancellationException) { throw cancelled }
catch (_: Exception) { /* Tried again next hour. */ }
delay(60 * 60_000L)
}
}
@@ -275,6 +282,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
LaunchedEffect(mainGuide, mainHome) {
if (!mainGuide && !mainHome) { watchingChannel = false; channelClockOffset = 0 }
}
LaunchedEffect(mainDriveIn) { if (mainDriveIn) driveInVisits++ else watchingDriveIn = false }
val ready = !busy && !restoring && now >= nextAttemptAt && username.isNotBlank() && password.text.isNotEmpty()
playback?.let { plan -> session?.let { active -> EmbyPlaybackDialog(active, plan, onClose = { playback = null }) } }
val liveSession = session
@@ -284,6 +292,17 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
}
val availableChannels = availableSampleChannels(channel, afterDarkChannel?.let(::onAir), toonamiChannel?.let(::onAir), *extraChannels.map(::onAir).toTypedArray())
val liveChannel = availableChannels.firstOrNull { it.number == selectedNumber }
if (mainDriveIn && liveSession != null) {
val booked = driveInPlan
val onScreen = remember(booked) { booked?.let(::driveInChannel) }
if (watchingDriveIn && onScreen != null) {
ScheduledChannelPlayer(liveSession, onScreen, 0, onClose = { watchingDriveIn = false }, onUnplayable = ::rememberUnplayable)
// Between shows there's nothing on, so the viewer comes back out to the lot.
LaunchedEffect(onScreen) { while (driveInAiring(onScreen.plan, System.currentTimeMillis())) delay(1000); watchingDriveIn = false }
}
DriveInScreen(liveSession, booked, driveInVisits, modifier, active = !watchingDriveIn, onWatch = { watchingDriveIn = true })
return
}
if ((mainGuide || mainHome) && liveSession != null && liveChannel != null) {
if (watchingChannel) ScheduledChannelPlayer(liveSession, liveChannel, channelClockOffset,
onClose = { watchingChannel = false; channelClockOffset = 0 },
@@ -307,7 +326,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
}
val picture = @Composable { GuidePicture(liveSession, liveChannel, live = !watchingChannel, handoff = liveHandoff, onUnplayable = ::rememberUnplayable) }
if (mainHome) HomeScreen(availableChannels, liveChannel, modifier, picture,
onCategory = { guideCategory = it; onGuide() }, onWatch = { tune(it, 0) }, onDriveIn = onDriveIn)
onCategory = { guideCategory = it; onGuide() }, onWatch = { tune(it, 0) }, onDriveIn = onDriveIn, driveInPlan = driveInPlan)
else GuideGrid(channelsIn(guideCategory, availableChannels).ifEmpty { availableChannels }, selectedNumber, modifier, guideCategory.label, picture,
onWatch = ::tune, onBack = { guideCategory = ChannelCategory.All; onHome() })
return
@@ -67,7 +67,7 @@ private fun ProgressBar(fraction: Float, modifier: Modifier = Modifier, focused:
/** Home opens onto the tuned channel playing, one press from anything else. */
@Composable
internal fun HomeScreen(channels: List<SampleChannel>, tuned: SampleChannel, modifier: Modifier = Modifier,
picture: @Composable () -> Unit, onCategory: (ChannelCategory) -> Unit, onWatch: (Int) -> Unit, onDriveIn: () -> Unit) {
picture: @Composable () -> Unit, onCategory: (ChannelCategory) -> Unit, onWatch: (Int) -> Unit, onDriveIn: () -> Unit, driveInPlan: ChannelPlan? = null) {
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) { while (true) { delay(1000); now = System.currentTimeMillis() } }
val firstFocus = remember { FocusRequester() }
@@ -119,16 +119,17 @@ internal fun HomeScreen(channels: List<SampleChannel>, tuned: SampleChannel, mod
}
}
}
val preview = driveInEvents.first()
val nowMs = System.currentTimeMillis()
val preview = driveInPlan?.let { plan -> driveInBills(plan).firstOrNull { it.endMs > nowMs } }
HomeCard(Modifier.fillMaxWidth().weight(1f), onClick = onDriveIn) { focused ->
Column {
Row(Modifier.fillMaxWidth().background(if (focused) Yellow else Color(0xFF7A5A12)).padding(horizontal = 10.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Image(painterResource(R.drawable.orbit_icon_office), null, Modifier.size(20.dp), colorFilter = if (focused) ColorFilter.tint(Navy) else null)
Text("Orbit Drive-In preview", color = if (focused) Navy else Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1)
Text("Orbit Drive-In", color = if (focused) Navy else Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1)
}
Column(Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(preview.title, color = if (focused) Navy else Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text("Fictional event, for now", color = if (focused) Navy else Muted, fontSize = 12.sp, maxLines = 1)
Text(preview?.title ?: "Tonight's bill is on its way", color = if (focused) Navy else Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (preview != null) Text("${preview.kind.label}, ${driveInWhen(preview, nowMs)}", color = if (focused) Navy else Muted, fontSize = 12.sp, maxLines = 1)
}
}
}
@@ -143,7 +143,7 @@ private fun OrbitApp() {
if (!realChannelAvailable) {
val tab = when (section) { "TV Guide" -> 1; "Drive-In" -> 2; else -> 0 }
tabFocus[tab].requestFocus()
} else if (section != "Home" && section != "TV Guide") gearFocus.requestFocus()
} else if (section != "Home" && section != "TV Guide" && section != "Drive-In") gearFocus.requestFocus()
}
}
Box(Modifier.fillMaxSize().background(Navy)) {
@@ -154,8 +154,9 @@ private fun OrbitApp() {
Text(programme.title, color = Color.White, fontSize = 26.sp, fontWeight = FontWeight.Bold)
Text(playbackError ?: "${clock(programme.start)}–${clock(programme.end)} • Demo signal ${elapsed(player.currentPosition)} / 30:00 • Back for guide", color = Muted, fontSize = 15.sp)
}
} else Column(Modifier.fillMaxSize().padding(horizontal = 36.dp, vertical = 16.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
} else Column(if (section == "Drive-In" && realChannelAvailable) Modifier.fillMaxSize() else Modifier.fillMaxSize().padding(horizontal = 36.dp, vertical = 16.dp)) {
// The Drive-In takes the whole screen once there are real films to show.
if (section != "Drive-In" || !realChannelAvailable) Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
androidx.compose.foundation.Image(androidx.compose.ui.res.painterResource(R.drawable.orbit_logo), "Orbit", Modifier.width(144.dp).height(36.dp))
Spacer(Modifier.weight(1f))
Text(SimpleDateFormat("EEE d MMM HH:mm", Locale.UK).format(Date(now)), color = Color.White, fontSize = 17.sp)
@@ -170,7 +171,7 @@ private fun OrbitApp() {
androidx.compose.foundation.Image(androidx.compose.ui.res.painterResource(R.drawable.orbit_icon_profile), "Profile", Modifier.size(38.dp, 32.dp).background(Panel).padding(5.dp))
}
}
Spacer(Modifier.height(8.dp))
if (section != "Drive-In" || !realChannelAvailable) Spacer(Modifier.height(8.dp))
// The numbered tabs only serve the demo guide. With real channels, home and the gear replace them.
if (!realChannelAvailable && restoreChecked) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -232,7 +233,7 @@ private fun OrbitApp() {
} else if (section == "Connect Emby") {
// Connection content is kept composed below to retain the temporary session.
} else if (section == "Drive-In") {
DriveIn(Modifier.weight(1f), onGuide = { section = "TV Guide" })
if (!realChannelAvailable) DriveIn(Modifier.weight(1f), onGuide = { section = "TV Guide" })
} else if (section != "TV Guide" && section != "Home") {
Column(Modifier.fillMaxWidth().weight(1f).background(Panel).padding(28.dp)) {
Text("Welcome to Orbit Interactive", color = Yellow, fontSize = 27.sp, fontWeight = FontWeight.Bold)
@@ -247,6 +248,7 @@ private fun OrbitApp() {
EmbyConnectionScreen(Modifier.weight(1f), visible = section == "Connect Emby",
mainGuide = section == "TV Guide" && realChannelAvailable,
mainHome = section == "Home" && realChannelAvailable,
mainDriveIn = section == "Drive-In" && realChannelAvailable,
onChannelAvailable = { realChannelAvailable = it }, onRestoreChecked = { restoreChecked = true },
onGuide = { section = "TV Guide" }, onHome = { section = "Home" }, onDriveIn = { section = "Drive-In" }, onBack = { section = "Menu" })
}
@@ -33,7 +33,7 @@ internal class SecureMediaDataSource(private val session: EmbySampleSession) : B
next.instanceFollowRedirects = false
next.connectTimeout = 10_000
next.readTimeout = 20_000
next.setRequestProperty("User-Agent", "OrbitTV/0.66.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.67.0")
next.setRequestProperty("Accept-Encoding", "identity")
session.mediaHeaders(address).forEach { (key, value) -> next.setRequestProperty(key, value) }
if (dataSpec.position != 0L || dataSpec.length != C.LENGTH_UNSET.toLong()) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.
@@ -0,0 +1,102 @@
package tv.orbit.classic
import org.junit.Assert.*
import org.junit.Test
import java.time.DayOfWeek
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
class DriveInTest {
private val london = ZoneId.of("Europe/London")
private val hour = 60 * 60_000L
private fun film(title: String, hours: Long = 2) = title.replace(Regex("[^A-Za-z0-9]"), "").let { id -> PoolSeries(id, title, listOf(PoolEpisode(id, "s$id", EpisodeMark(1, 1), title, hours * hour))) }
private fun at(day: Int, h: Int, m: Int = 0) = ZonedDateTime.of(2026, 11, day, h, m, 0, 0, london).toInstant().toEpochMilli()
private val big = (1..30).map { film("Big $it") }
private val late = (1..30).map { film("Late $it") }
private val bills = listOf(film("Alien"), film("Aliens"), film("Back to the Future"), film("Back to the Future Part II"), film("Back to the Future Part III"))
private fun local(ms: Long) = Instant.ofEpochMilli(ms).atZone(london)
@Test fun fridayIsADoubleBillAndSaturdayATriple() {
val plan = planDriveIn(null, big, late, bills, at(2, 10), london)!!
val all = driveInBills(plan, london)
val double = all.first { it.kind == DriveInKind.Double }
assertEquals(DayOfWeek.FRIDAY, local(double.startMs).dayOfWeek)
assertEquals(20, local(double.startMs).hour)
assertEquals(listOf("Alien", "Aliens"), double.films)
val triple = all.first { it.kind == DriveInKind.Triple }
assertEquals(DayOfWeek.SATURDAY, local(triple.startMs).dayOfWeek)
assertEquals(19, local(triple.startMs).hour)
assertEquals(3, triple.films.size)
}
@Test fun otherNightsHaveAMarqueeThenALateShowWithNoOverlap() {
val plan = planDriveIn(null, big, late, bills, at(2, 10), london)!!
val monday = driveInBills(plan, london).filter { it.startMs in at(2, 6)..at(3, 5) }
assertEquals(listOf(DriveInKind.Marquee, DriveInKind.LateShow), monday.map { it.kind })
assertEquals(20, local(monday[0].startMs).hour)
assertEquals(23, local(monday[1].startMs).hour)
plan.entries.zipWithNext { a, b -> assertTrue(a.endMs <= b.startMs) }
}
@Test fun aLongFeaturePushesTheLateShowBack() {
val plan = planDriveIn(null, listOf(film("Epic", 4), film("Epic Two", 4)), late, bills, at(2, 10), london)!!
val monday = driveInBills(plan, london).filter { it.startMs in at(2, 6)..at(3, 5) }
assertEquals(at(3, 0, 15), monday[1].startMs)
}
@Test fun whatIsBookedStaysBooked() {
val first = planDriveIn(null, big, late, bills, at(2, 10), london)!!
val later = planDriveIn(first, big, late, bills, at(3, 10), london)!!
val kept = first.entries.filter { it.endMs >= at(3, 10) - SCHEDULE_KEEP_PAST_MS }
assertEquals(kept.map { it.media.item.id to it.startMs }, later.entries.take(kept.size).map { it.media.item.id to it.startMs })
assertTrue(later.endMs > first.endMs)
}
@Test fun nothingNewIsNeededOnceAWeekIsBooked() {
val first = planDriveIn(null, big, late, bills, at(2, 10), london)!!
assertTrue(driveInBookedAhead(first, at(2, 11), london))
assertFalse(driveInBookedAhead(first, at(3, 11), london))
assertFalse(driveInBookedAhead(null, at(2, 11), london))
}
@Test fun aBillWithAFilmMissingGivesWayToAMarquee() {
val plan = planDriveIn(null, big, late, listOf(film("Alien")), at(2, 10), london)!!
assertTrue(driveInBills(plan, london).none { it.kind == DriveInKind.Double || it.kind == DriveInKind.Triple })
}
@Test fun aBillAvoidsAFilmOnAnotherChannelAtTheSameTime() {
val clash: ProgrammeClash = { show, _, _ -> show.title == "Aliens" }
val plan = planDriveIn(null, big, late, bills, at(2, 10), london, clash)!!
assertTrue(plan.entries.none { it.media.item.title == "Aliens" })
}
@Test fun anEmptyLibraryBooksNothing() {
assertNull(planDriveIn(null, emptyList(), late, bills, at(2, 10), london))
}
@Test fun theLineupIsTheNextOfEachKind() {
val plan = planDriveIn(null, big, late, bills, at(2, 10), london)!!
val lineup = driveInLineup(driveInBills(plan, london), at(2, 10))
assertEquals(DriveInKind.values().toList(), lineup.map { it.kind })
}
@Test fun dayNamesCountTheSmallHoursAsTheNightBefore() {
val plan = planDriveIn(null, big, late, bills, at(2, 10), london)!!
val tonight = driveInBills(plan, london).first { it.kind == DriveInKind.Marquee }
assertEquals("Tonight", driveInDay(tonight, at(2, 10), london))
assertEquals("Now showing", driveInDay(tonight, tonight.startMs, london))
assertEquals("Tonight", driveInDay(tonight, at(2, 19), london))
}
@Test fun airingIsFalseBetweenShows() {
val plan = planDriveIn(null, big, late, bills, at(2, 10), london)!!
assertTrue(driveInAiring(plan, at(2, 21)))
assertFalse(driveInAiring(plan, at(2, 15)))
assertNotNull(driveInChannel(plan))
}
@Test fun theDriveInIsBranded() {
assertEquals("Orbit Drive-In", ChannelBrands.forNumber(DRIVE_IN_NUMBER)!!.displayName)
}
}
+8 -3
View File
@@ -74,7 +74,8 @@ for i, (day, name, at, on) in enumerate(rows):
ink, sub = ('#071632', '#071632') if on else ('#F5F7FF', '#F2C14E')
bill += text(1242, y + 40, day, 25, sub, 'Barlow', 'font-weight="600" letter-spacing="3"') + text(1242, y + 84, name, 38, ink, 'Barlow', 'font-weight="600"') + text(1760, y + 72, at, 40, ink, 'Barlow Condensed')
stars = ''.join(f'<circle cx="{(i * 373) % 1920}" cy="{(i * 197) % 360}" r="{1 + i % 2}" fill="#FFFFFF" fill-opacity="{.25 + (i % 5) * .12:.2f}"/>' for i in range(90))
SIDE = ('<rect x="1192" y="70" width="704" height="300" rx="14" fill="#0B1F47" stroke="url(#gold)" stroke-width="3"/>' + bulbs(1210, 88, 668, 264, 30, 4.5) + mark(1214, 92, .32) + orbit(1394, 156, 28, 11) + word(1392, 300, 112) + bill)
SIGN = ('<rect x="1192" y="70" width="704" height="300" rx="14" fill="#0B1F47" stroke="url(#gold)" stroke-width="3"/>' + bulbs(1210, 88, 668, 264, 30, 4.5) + mark(1214, 92, .32) + orbit(1394, 156, 28, 11) + word(1392, 300, 112))
SIDE = SIGN + bill
HINTS = text(40, 1048, 'OK Get your ticket', 26, '#AEBBD2', 'Barlow') + text(320, 1048, '▲▼ Choose an event', 26, '#AEBBD2', 'Barlow') + text(630, 1048, 'Back Leave the lot', 26, '#AEBBD2', 'Barlow')
EXTRA = ('<defs><linearGradient id="wood" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7A4C2A"/><stop offset=".5" stop-color="#5A3419"/><stop offset="1" stop-color="#3A2010"/></linearGradient>'
'<linearGradient id="foyer" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFE9A8"/><stop offset="1" stop-color="#E89A3C"/></linearGradient>'
@@ -82,14 +83,18 @@ EXTRA = ('<defs><linearGradient id="wood" x1="0" y1="0" x2="1" y2="1"><stop offs
'<radialGradient id="lamp"><stop offset="0" stop-color="#FFB866" stop-opacity=".3"/><stop offset="1" stop-color="#FFB866" stop-opacity="0"/></radialGradient>'
'<radialGradient id="tube" cx=".5" cy=".5" r=".72"><stop offset=".62" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".62"/></radialGradient>'
'<clipPath id="crt"><path d="M160 152C340 132 680 132 860 152C884 300 884 640 860 788C680 808 340 808 160 788C136 640 136 300 160 152Z"/></clipPath></defs>')
def crt():
def crt(on=True):
grille = ''.join(f'<path d="M944 {y}H1094" stroke="#1A0F08" stroke-width="5"/>' for y in range(520, 770, 18))
lines = ''.join(f'<path d="M136 {y}H884" stroke="#000" stroke-opacity=".16" stroke-width="2"/>' for y in range(136, 806, 6))
grain = ''.join(f'<path d="M64 {y}C340 {y + 10} 800 {y - 10} 1136 {y + 4}" stroke="#2E1A0C" stroke-opacity=".4" stroke-width="2" fill="none"/>' for y in range(112, 868, 42))
dial = lambda y: f'<circle cx="1018" cy="{y}" r="50" fill="#C9CDD4"/><circle cx="1018" cy="{y}" r="36" fill="#23262C"/><path d="M1018 {y}L1042 {y - 24}" stroke="#F5F1E0" stroke-width="6" stroke-linecap="round"/>'
return ('<rect width="1920" height="1080" fill="#0A1228"/><circle cx="600" cy="480" r="760" fill="url(#lamp)"/>' +
'<rect x="60" y="90" width="1080" height="780" rx="38" fill="url(#wood)"/>' + grain + '<rect x="108" y="124" width="804" height="692" rx="52" fill="#0A0806"/>' +
picture(136, 136, 748, 668, 42, 'crt') + f'<g clip-path="url(#crt)">{lines}<rect x="136" y="136" width="748" height="668" fill="url(#tube)"/><path d="M190 180C330 164 480 164 610 176C470 200 310 240 190 300Z" fill="#FFFFFF" fill-opacity=".07"/></g>' +
(picture(136, 136, 748, 668, 42, 'crt') + f'<g clip-path="url(#crt)">{lines}<rect x="136" y="136" width="748" height="668" fill="url(#tube)"/><path d="M190 180C330 164 480 164 610 176C470 200 310 240 190 300Z" fill="#FFFFFF" fill-opacity=".07"/></g>' if on else '') +
'<rect x="928" y="124" width="182" height="692" rx="10" fill="#3A2010"/>' + dial(218) + dial(366) + grille + text(1019, 796, 'ORBIT', 26, '#F5F1E0', 'Barlow', 'font-weight="600" text-anchor="middle" letter-spacing="5"') +
'<path d="M160 870V920M1040 870V920" stroke="#1A0F08" stroke-width="22"/><rect y="920" width="1920" height="160" fill="#1A0F08"/><path d="M0 920H1920" stroke="#3A2010" stroke-width="5"/>' + SIDE + HINTS)
write('drive_in_screen_crt', 1920, 1080, EXTRA + crt())
# What the app draws on: the set and the sign, with the screen left empty and the bill to come.
SIDE, HINTS = SIGN, ''
blank = crt(False)
write('drive_in_crt_blank', 1920, 1080, EXTRA + blank)
+15 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""The theatre and drive-in screens, from Paul's two concept pictures in references/. The old name comes off the sign, the marquee board
and the big screen, leaving blank scenes the app can write on. With an output folder and a picture it also makes mock-ups with sample text."""
import subprocess, sys
import subprocess, sys, tempfile
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw, ImageFilter
HERE = Path(__file__).parent
@@ -116,3 +116,17 @@ if picture:
else:
theatre().save(HERE / 'theatre_blank.webp', quality=88)
drive_in().save(HERE / 'drive_in_blank.webp', quality=88)
# The app's own copies, with the sign lit and room left for it to write on.
RES = HERE.parents[1] / 'android/app/src/main/res/drawable-nodpi'
OUT = Path(tempfile.mkdtemp())
for scene, sign, name in ((theatre(), logo(600, 92, .42, 824, 168, 34, 302, 136), 'drive_in_theatre'), (drive_in(), logo(494, 158, .3, 642, 216, 25, 322, 96), 'drive_in_lot')):
lit(scene, overlay(name, sign), 14 if name.endswith('theatre') else 9)
scene.resize((1280, 720), Image.LANCZOS).save(RES / f'{name}.webp', quality=84)
Image.open(HERE / 'drive_in_crt_blank.png').convert('RGB').resize((1280, 720), Image.LANCZOS).save(RES / 'drive_in_crt.webp', quality=84)
def small(w, h, body, size, name):
src = OUT / f'{name}.svg'
src.write_text(f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">{DEFS}{body}</svg>')
subprocess.run(['vectorcraft-cli', 'convert', str(src), str(src.with_suffix('.png'))], check=True, stdout=subprocess.DEVNULL)
Image.open(src.with_suffix('.png')).convert('RGBA').resize(size, Image.LANCZOS).save(RES / f'{name}.webp', quality=92)
small(512, 160, logo(-6, 1, .26, 134, 50, 18, 136, 78), (256, 80), 'orbit_drive_in_epg')
small(384, 128, logo(-6, -3, .2, 106, 38, 15, 108, 66), (144, 48), 'orbit_drive_in_bug')
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 949 KiB

After

Width:  |  Height:  |  Size: 184 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 293 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+1
View File
@@ -95,6 +95,7 @@ Paul's ideas, 1 November 2026. Not fixes and not scheduled.
- **Subtitles default (Paul, 1 November 2026):** a setting behind the gear for subtitles on or off by default, in the viewer's language where a programme has it.
- **Orbit Drive-In name (Paul, 1 November 2026):** decided, replacing OrbitOffice. Event types inside it are Orbit Marquee, Orbit Double Bill, Orbit Triple Bill and The Late Show. The briefs and design/interface still show the old name.
- **Orbit Drive-In (Paul, 2 November 2026):** built in 0.67 with three scenes that take turns: his theatre and drive-in pictures, and the CRT. Marquee nights, Friday double bills, Saturday triple bills and late shows from his films, booked a week ahead. Still to come: reminders from a ticket.
- **Orbit Arcade (Paul, 2 November 2026):** the look in design/arcade is approved: logo, honeycomb menu and Bedlam screen. Games are OrbitHive Bedlam, Snake, Block Drop, Brick Break, Pairs and Patience. Small games only, so the app stays small: drawn in code with no game engine, and measured for size when built. Not scheduled.
## Channel numbers