Add Drive-In tickets and in-app reminders (0.71.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m38s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m32s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-02 13:03:28 +00:00
co-authored by Claude Opus 5.5
parent e4105eebeb
commit 7e42e65fb3
9 changed files with 173 additions and 22 deletions
+10
View File
@@ -0,0 +1,10 @@
# Orbit TV 0.71.0: Drive-In tickets and reminders
- OK on a later show now asks "Get your ticket?". With one, the tile says TICKET, and the same press shows it, with the option to hand it back. Tickets are kept on the box.
- In-app reminders: 5 minutes before, offering Go to the Drive-In or Later, and when it starts, offering Watch now or Not tonight. The card floats top right over anything, including full screen, which keeps playing. It clears itself after a minute.
- A show that started up to 15 minutes ago still reminds, in case the app was closed. There's no alert while the app is closed.
- Fixed while here: forgetting the account would have removed the mark that the number move was done, which could have moved channels again on the next launch.
Checked: build, lint and 278 unit tests pass, including reminder timing, tickets after a restart and that fix.
Not checked: the prompt and reminder card on the TV. The first real run is a ticket for tonight's 20:00 show, with its reminder at 19:55.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 73
versionName = "0.70.2-level-columns"
versionCode = 74
versionName = "0.71.0-drive-in-tickets"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -28,6 +28,7 @@ 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.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -37,6 +38,10 @@ 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.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.tv.material3.Text
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -186,6 +191,25 @@ internal fun driveInChannel(plan: ChannelPlan): ScheduledChannel? {
return ScheduledChannel(SampleChannel(films, "Orbit Drive-In", DRIVE_IN_NUMBER), plan)
}
internal const val REMINDER_BEFORE_MS = 5 * 60_000L
internal const val REMINDER_LATE_MS = 15 * 60_000L
/** A reminder that's due. Stage 1 is the warning before a show, and stage 2 is when it starts. */
internal class DriveInReminder(val bill: DriveInBill, val stage: Int)
/** Shown once each. A show that started up to 15 minutes ago still counts, in case the app was closed at the time. */
internal fun driveInReminderDue(bills: List<DriveInBill>, tickets: Map<Long, Int>, nowMs: Long): DriveInReminder? {
for (bill in bills) {
val done = tickets[bill.startMs] ?: continue
if (nowMs >= bill.startMs && nowMs < minOf(bill.startMs + REMINDER_LATE_MS, bill.endMs) && done < 2) return DriveInReminder(bill, 2)
if (nowMs >= bill.startMs - REMINDER_BEFORE_MS && nowMs < bill.startMs && done < 1) return DriveInReminder(bill, 1)
}
return null
}
/** Old tickets drop off, so the file stays small. */
internal fun liveTickets(tickets: Map<Long, Int>, nowMs: Long) = tickets.filterKeys { it > nowMs - 24 * 60 * 60_000L }
private val Marquee = FontFamily(Font(R.font.anton_regular))
private val BoardInk = Color(0xFF2A1238)
private val BoardPink = Color(0xFFC2358F)
@@ -236,13 +260,13 @@ private fun MarqueeBoard(bill: DriveInBill?, nowMs: Long, x: Dp, y: Dp, width: D
}
@Composable
private fun EventTile(bill: DriveInBill, nowMs: Long, selected: Boolean, modifier: Modifier) {
private fun EventTile(bill: DriveInBill, nowMs: Long, selected: Boolean, ticketed: 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(driveInDay(bill, nowMs).uppercase(java.util.Locale.UK) + if (ticketed) " • TICKET" else "", 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)
@@ -288,20 +312,34 @@ private fun PictureOnTube(picture: Bitmap?) {
}
}
/** Picking a later show asks first. With a ticket, the same place shows it, and it can be handed back. */
@Composable
private fun DriveInTicket(bill: DriveInBill, nowMs: Long, onClose: () -> Unit) {
private fun DriveInTicket(bill: DriveInBill, nowMs: Long, ticketed: Boolean, onTicket: () -> Unit, onHandBack: () -> Unit, onClose: () -> Unit) {
BackHandler(onBack = onClose)
val firstButton = remember { FocusRequester() }
LaunchedEffect(ticketed) { firstButton.requestFocus() }
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)) {
Row(Modifier.width(640.dp).height(290.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.height(8.dp))
Text(bill.title, color = Color.White, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 26.sp, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (bill.films.size > 1) Text(bill.films.joinToString(" • "), color = Muted, fontFamily = Barlow, fontSize = 12.sp, maxLines = 2, 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)
Text(if (ticketed) "You have your ticket. We'll remind you 5 minutes before, and when it starts." else "Get your ticket? We'll remind you 5 minutes before, and when it starts.",
color = Color.White, fontFamily = Barlow, fontSize = 12.sp, maxLines = 2)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (ticketed) {
OrbitButton("Done", Modifier.focusRequester(firstButton), onClick = onClose)
OrbitButton("Hand it back", onClick = onHandBack)
} else {
OrbitButton("Yes, get my ticket", Modifier.focusRequester(firstButton), onClick = onTicket)
OrbitButton("No thanks", onClick = onClose)
}
}
}
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) {
@@ -313,9 +351,31 @@ private fun DriveInTicket(bill: DriveInBill, nowMs: Long, onClose: () -> Unit) {
}
}
/** Over whatever's on, including full screen, which keeps playing behind it. It clears itself if nobody answers. */
@Composable
internal fun DriveInReminderCard(reminder: DriveInReminder, nowMs: Long, onGo: () -> Unit, onDismiss: () -> Unit) {
LaunchedEffect(reminder.bill.startMs, reminder.stage) { delay(60_000); onDismiss() }
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false, securePolicy = SecureFlagPolicy.SecureOn)) {
val window = (LocalView.current.parent as? DialogWindowProvider)?.window
SideEffect { window?.setDimAmount(0f); window?.setGravity(android.view.Gravity.TOP or android.view.Gravity.END) }
val first = remember { FocusRequester() }
LaunchedEffect(reminder.stage) { first.requestFocus() }
Column(Modifier.padding(20.dp).width(380.dp).background(Color(0xF00E2147), RoundedCornerShape(6.dp)).border(2.dp, Gold, RoundedCornerShape(6.dp)).padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
val left = ((reminder.bill.startMs - nowMs + 59_999) / 60_000).coerceAtLeast(1)
Text(if (reminder.stage == 2) "NOW SHOWING AT THE DRIVE-IN" else "AT THE DRIVE-IN IN $left ${if (left == 1L) "MINUTE" else "MINUTES"}", color = Gold, fontFamily = Marquee, fontSize = 16.sp, maxLines = 1)
Text(reminder.bill.title, color = Color.White, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = 19.sp, maxLines = 2, overflow = TextOverflow.Ellipsis)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OrbitButton(if (reminder.stage == 2) "Watch now" else "Go to the Drive-In", Modifier.focusRequester(first), onClick = onGo)
OrbitButton(if (reminder.stage == 2) "Not tonight" else "Later", onClick = onDismiss)
}
}
}
}
/** 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) {
internal fun DriveInScreen(session: EmbySampleSession, plan: ChannelPlan?, visit: Int, modifier: Modifier, active: Boolean, tickets: Map<Long, Int>,
onTicket: (DriveInBill) -> Unit, onHandBack: (DriveInBill) -> Unit, onWatch: () -> Unit) {
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) { while (true) { delay(1000); now = System.currentTimeMillis() } }
val minute = now / 60_000
@@ -330,7 +390,8 @@ internal fun DriveInScreen(session: EmbySampleSession, plan: ChannelPlan?, visit
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
// With the ticket open, the keys are for its buttons.
if (native.action != KeyEvent.ACTION_DOWN || lineup.isEmpty() || ticket != null) 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 }
@@ -361,18 +422,22 @@ internal fun DriveInScreen(session: EmbySampleSession, plan: ChannelPlan?, visit
}
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)) }
lineup.forEach { EventTile(it, now, it === selected, it.startMs in tickets, 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)) }
lineup.forEach { EventTile(it, now, it === selected, it.startMs in tickets, 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"
val hint = when {
selected != null && selected.startMs <= now -> "OK Watch"
selected != null && selected.startMs in tickets -> "OK Your ticket"
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 }) }
ticket?.let { bill -> DriveInTicket(bill, now, bill.startMs in tickets, onTicket = { onTicket(bill) }, onHandBack = { onHandBack(bill); ticket = null }, 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.70.2\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.71.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -94,6 +94,9 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
var watchingChannel by remember { mutableStateOf(false) }
var watchingDriveIn by remember { mutableStateOf(false) }
var driveInPlan by remember { mutableStateOf<ChannelPlan?>(null) }
var tickets by remember { mutableStateOf<Map<Long, Int>>(emptyMap()) }
var pendingDriveInWatch by remember { mutableStateOf(false) }
var reminder by remember { mutableStateOf<DriveInReminder?>(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) }
@@ -247,7 +250,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
LaunchedEffect(session, extraChannels, channel, afterDarkChannel, toonamiChannel) {
val active = session ?: return@LaunchedEffect
// What's booked shows straight away. The rounds below take a while with this many channels.
try { driveInPlan = withContext(Dispatchers.IO) { scheduleStore.loadPlan(active.accountId, DRIVE_IN_NUMBER) } }
try { withContext(Dispatchers.IO) { driveInPlan = scheduleStore.loadPlan(active.accountId, DRIVE_IN_NUMBER); tickets = liveTickets(scheduleStore.loadTickets(active.accountId), System.currentTimeMillis()) } }
catch (cancelled: CancellationException) { throw cancelled }
catch (_: Exception) { /* The round below tries again. */ }
while (true) {
@@ -286,7 +289,33 @@ 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 }
LaunchedEffect(mainDriveIn) {
if (mainDriveIn) { driveInVisits++; if (pendingDriveInWatch) { pendingDriveInWatch = false; watchingDriveIn = true } } else watchingDriveIn = false
}
fun saveTickets(changed: Map<Long, Int>) {
tickets = changed
val active = session ?: return
scope.launch(Dispatchers.IO) { runCatching { scheduleStore.saveTickets(active.accountId, changed) } }
}
// Checked wherever the viewer is. The reminder floats over the player, which carries on underneath.
LaunchedEffect(driveInPlan, tickets, watchingDriveIn) {
val booked = driveInPlan
while (booked != null) {
val time = System.currentTimeMillis()
val due = driveInReminderDue(driveInBills(booked), tickets, time)
// Already watching it, so there's nothing to remind about.
if (due != null && due.stage == 2 && watchingDriveIn) saveTickets(tickets + (due.bill.startMs to 2)) else if (due?.stage != reminder?.stage || due?.bill?.startMs != reminder?.bill?.startMs) reminder = due
delay(5_000)
}
}
reminder?.let { due ->
fun answered() { saveTickets(tickets + (due.bill.startMs to due.stage)); reminder = null }
DriveInReminderCard(due, System.currentTimeMillis(), onDismiss = ::answered, onGo = {
answered()
if (due.stage == 2) { if (mainDriveIn) watchingDriveIn = true else pendingDriveInWatch = true }
onDriveIn()
})
}
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
@@ -304,7 +333,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
// 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 })
DriveInScreen(liveSession, booked, driveInVisits, modifier, active = !watchingDriveIn, tickets = tickets,
onTicket = { saveTickets(tickets + (it.startMs to 0)) }, onHandBack = { saveTickets(tickets - it.startMs) }, onWatch = { watchingDriveIn = true })
return
}
if ((mainGuide || mainHome) && liveSession != null && liveChannel != null) {
@@ -140,6 +140,14 @@ internal class ScheduleStore(private val directory: File) {
(0 until items.length()).map { validId(items.getString(it)) }.toSet()
}.orEmpty()
/** Drive-In tickets, by the bill's start time, with how far their reminders have got. */
fun saveTickets(userId: String, tickets: Map<Long, Int>) =
write(file("tickets", 0), root(userId, 0).put("tickets", JSONObject().also { saved -> tickets.forEach { (start, stage) -> saved.put(start.toString(), stage) } }))
fun loadTickets(userId: String): Map<Long, Int> = read(file("tickets", 0), userId, 0) { root ->
val saved = root.getJSONObject("tickets")
saved.keys().asSequence().associate { it.toLong() to saved.getInt(it) }
}.orEmpty()
/** Your accepted and rejected suggestions. Kept apart from the schedule, so a rebuild or a damaged plan never loses them. */
fun saveChoices(userId: String, number: Int, choices: ChannelChoices) {
val accepted = JSONArray()
@@ -164,5 +172,5 @@ internal class ScheduleStore(private val directory: File) {
fun sizes(number: Int) = listOf("episodes", "schedule", "progress", "choices").associateWith { file(it, number).length() }
/** On forgetting the account. Rollback never needs this: the channel simply goes back to its loop. */
fun clear() { directory.listFiles()?.forEach { it.delete() } }
fun clear() { directory.listFiles()?.filter { it.name != "numbering-2" }?.forEach { it.delete() } }
}
@@ -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.70.2")
next.setRequestProperty("User-Agent", "OrbitTV/0.71.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()) {
@@ -107,4 +107,42 @@ class DriveInTest {
assertEquals("Alien and Aliens", all.first { it.kind == DriveInKind.Double }.title)
assertTrue(all.first { it.kind == DriveInKind.Marquee }.title.startsWith("Big"))
}
private fun tonight() = driveInBills(planDriveIn(null, big, late, bills, at(2, 10), london)!!, london)
private val minute = 60_000L
@Test fun aReminderComesFiveMinutesBeforeAndAgainAtTheStart() {
val all = tonight(); val show = all.first()
val tickets = mapOf(show.startMs to 0)
assertNull(driveInReminderDue(all, tickets, show.startMs - 6 * minute))
assertEquals(1, driveInReminderDue(all, tickets, show.startMs - 5 * minute)!!.stage)
assertEquals(2, driveInReminderDue(all, tickets, show.startMs)!!.stage)
assertNull(driveInReminderDue(all, mapOf(show.startMs to 1), show.startMs - minute))
assertEquals(2, driveInReminderDue(all, mapOf(show.startMs to 1), show.startMs)!!.stage)
assertNull(driveInReminderDue(all, mapOf(show.startMs to 2), show.startMs))
}
@Test fun noTicketMeansNoReminderAndALateOneStops() {
val all = tonight(); val show = all.first()
assertNull(driveInReminderDue(all, emptyMap(), show.startMs))
assertNull(driveInReminderDue(all, mapOf(show.startMs to 0), show.startMs + REMINDER_LATE_MS))
}
@Test fun ticketsSurviveARestartAndOldOnesDropOff() {
val folder = java.nio.file.Files.createTempDirectory("tickets").toFile()
val store = ScheduleStore(folder)
store.saveTickets("user", mapOf(5_000L to 1))
assertEquals(mapOf(5_000L to 1), ScheduleStore(folder).loadTickets("user"))
assertTrue(store.loadTickets("someone-else").isEmpty())
assertEquals(setOf(at(2, 20)), liveTickets(mapOf(at(2, 20) to 0, at(1, 2) to 0), at(2, 10)).keys)
}
@Test fun forgettingTheAccountKeepsTheNumberMoveDone() {
val folder = java.nio.file.Files.createTempDirectory("clear").toFile()
migrateChannelNumbers(folder)
ScheduleStore(folder).clear()
java.io.File(folder, "schedule-102.json").writeText("""{"channel":102}""")
migrateChannelNumbers(folder)
assertTrue(java.io.File(folder, "schedule-102.json").exists())
}
}
+1 -1
View File
@@ -95,7 +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 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. Tickets and in-app reminders came in 0.71, 5 minutes before and at the start.
- **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