diff --git a/VERIFICATION-0.67.0.md b/VERIFICATION-0.67.0.md new file mode 100644 index 0000000..e7b20ad --- /dev/null +++ b/VERIFICATION-0.67.0.md @@ -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2e17f2d..97245c0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 } diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt b/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt index 7fead54..dc6108f 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt @@ -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), diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt index 7172273..121dbc9 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt @@ -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) = 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) diff --git a/android/app/src/main/java/tv/orbit/classic/DriveIn.kt b/android/app/src/main/java/tv/orbit/classic/DriveIn.kt new file mode 100644 index 0000000..926fc08 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/DriveIn.kt @@ -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) { + 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, late: List, bills: List, 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>, startMs: Long, week: Long): List? = 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 { + val runs = mutableListOf>() + 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, nowMs: Long): List = + 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(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(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(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 }) } + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt index f4efed3..25b558f 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -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) 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 bdbed29..9014292 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -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(null) } var watchingChannel by remember { mutableStateOf(false) } + var watchingDriveIn by remember { mutableStateOf(false) } + var driveInPlan by remember { mutableStateOf(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 diff --git a/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt b/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt index 1c13846..a55dc76 100644 --- a/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt @@ -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, 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, 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) } } } diff --git a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt index f95106a..eba687c 100644 --- a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt +++ b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt @@ -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" }) } diff --git a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt index 68e34da..1a5277c 100644 --- a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt +++ b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt @@ -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()) { diff --git a/android/app/src/main/res/drawable-nodpi/drive_in_crt.webp b/android/app/src/main/res/drawable-nodpi/drive_in_crt.webp new file mode 100644 index 0000000..d42dd77 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/drive_in_crt.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/drive_in_lot.webp b/android/app/src/main/res/drawable-nodpi/drive_in_lot.webp new file mode 100644 index 0000000..bbc2595 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/drive_in_lot.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/drive_in_theatre.webp b/android/app/src/main/res/drawable-nodpi/drive_in_theatre.webp new file mode 100644 index 0000000..3d9d78e Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/drive_in_theatre.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_drive_in_bug.webp b/android/app/src/main/res/drawable-nodpi/orbit_drive_in_bug.webp new file mode 100644 index 0000000..744f095 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_drive_in_bug.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_drive_in_epg.webp b/android/app/src/main/res/drawable-nodpi/orbit_drive_in_epg.webp new file mode 100644 index 0000000..dbfe680 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_drive_in_epg.webp differ diff --git a/android/app/src/main/res/font/anton_regular.ttf b/android/app/src/main/res/font/anton_regular.ttf new file mode 100644 index 0000000..3eb0e15 Binary files /dev/null and b/android/app/src/main/res/font/anton_regular.ttf differ diff --git a/android/app/src/test/java/tv/orbit/classic/DriveInTest.kt b/android/app/src/test/java/tv/orbit/classic/DriveInTest.kt new file mode 100644 index 0000000..84ef772 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/DriveInTest.kt @@ -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) + } +} diff --git a/design/drive-in/build-drive-in.py b/design/drive-in/build-drive-in.py index 3818934..723f86f 100644 --- a/design/drive-in/build-drive-in.py +++ b/design/drive-in/build-drive-in.py @@ -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'' for i in range(90)) -SIDE = ('' + bulbs(1210, 88, 668, 264, 30, 4.5) + mark(1214, 92, .32) + orbit(1394, 156, 28, 11) + word(1392, 300, 112) + bill) +SIGN = ('' + 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 = ('' '' @@ -82,14 +83,18 @@ EXTRA = ('' '' '') -def crt(): +def crt(on=True): grille = ''.join(f'' for y in range(520, 770, 18)) lines = ''.join(f'' for y in range(136, 806, 6)) grain = ''.join(f'' for y in range(112, 868, 42)) dial = lambda y: f'' return ('' + '' + grain + '' + - picture(136, 136, 748, 668, 42, 'crt') + f'{lines}' + + (picture(136, 136, 748, 668, 42, 'crt') + f'{lines}' if on else '') + '' + dial(218) + dial(366) + grille + text(1019, 796, 'ORBIT', 26, '#F5F1E0', 'Barlow', 'font-weight="600" text-anchor="middle" letter-spacing="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) diff --git a/design/drive-in/build-scenes.py b/design/drive-in/build-scenes.py index 8be961c..d54d40a 100644 --- a/design/drive-in/build-scenes.py +++ b/design/drive-in/build-scenes.py @@ -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'{DEFS}{body}') + 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') diff --git a/design/drive-in/drive_in_crt_blank.png b/design/drive-in/drive_in_crt_blank.png new file mode 100644 index 0000000..9a108c5 Binary files /dev/null and b/design/drive-in/drive_in_crt_blank.png differ diff --git a/design/drive-in/drive_in_crt_blank.svg b/design/drive-in/drive_in_crt_blank.svg new file mode 100644 index 0000000..1349a7b --- /dev/null +++ b/design/drive-in/drive_in_crt_blank.svg @@ -0,0 +1 @@ +ORBITORBITDRIVE-IN \ No newline at end of file diff --git a/design/drive-in/drive_in_screen_crt.png b/design/drive-in/drive_in_screen_crt.png index fd68ca2..cc432aa 100644 Binary files a/design/drive-in/drive_in_screen_crt.png and b/design/drive-in/drive_in_screen_crt.png differ diff --git a/design/drive-in/drive_in_screen_crt.svg b/design/drive-in/drive_in_screen_crt.svg index 16ab5b7..b7d82a9 100644 --- a/design/drive-in/drive_in_screen_crt.svg +++ b/design/drive-in/drive_in_screen_crt.svg @@ -1 +1 @@ -TONIGHT'S PRESENTATIONTHE FEATURE TITLEORBITORBITDRIVE-INTONIGHTOrbit Marquee21:30FRIDAYOrbit Double Bill20:00SATURDAYOrbit Triple Bill19:00LATEThe Late Show23:00OK Get your ticket▲▼ Choose an eventBack Leave the lot \ No newline at end of file +TONIGHT'S PRESENTATIONTHE FEATURE TITLEORBITORBITDRIVE-INTONIGHTOrbit Marquee21:30FRIDAYOrbit Double Bill20:00SATURDAYOrbit Triple Bill19:00LATEThe Late Show23:00OK Get your ticket▲▼ Choose an eventBack Leave the lot \ No newline at end of file diff --git a/docs/roadmap.md b/docs/roadmap.md index 5f9bf8e..b2c482f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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