diff --git a/VERIFICATION-0.68.0.md b/VERIFICATION-0.68.0.md new file mode 100644 index 0000000..5623061 --- /dev/null +++ b/VERIFICATION-0.68.0.md @@ -0,0 +1,10 @@ +# Orbit TV 0.68.0: Orbit 1, 2 and Three take 101 to 103 + +- Numbers: Orbit 2 moves 104 to 102 and Orbit Three 112 to 103, with Vortex moving 103 to 120 and After Dark 102 to 130. Files on the box move once at first launch, so every schedule, place and review choice carries on. The move can resume after a power cut. The catalogue isn't touched. +- Orbit 1 can now be a smart channel, from a new list of family favourites and comfort TV. Paul switches it on in Smart channels. +- Drive-In board text sits on fixed lines, centred on each board, and the drive-in's board follows its slight tilt. Bills have names, such as The Toy Story Trilogy. +- App icon and TV banner are centred on what's actually drawn. + +Checked: build, lint and all unit tests pass, including the number move, an interrupted move, an old saved file and bill names. Icons checked with centre lines on a sheet. + +Not checked: the board text on the TV, where screenshots are blocked; Paul's photo is the check. Live tune of a bill and return to the lot still to be seen at 20:00. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 97245c0..5da1dc5 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 = 68 - versionName = "0.67.0-drive-in" + versionCode = 69 + versionName = "0.68.0-orbit-numbers" } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } diff --git a/android/app/src/main/java/tv/orbit/classic/AfterDarkReviewScreen.kt b/android/app/src/main/java/tv/orbit/classic/AfterDarkReviewScreen.kt index e20f4d6..c63570b 100644 --- a/android/app/src/main/java/tv/orbit/classic/AfterDarkReviewScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/AfterDarkReviewScreen.kt @@ -23,7 +23,7 @@ internal fun AfterDarkReviewScreen(movies: List, approved: Set OrbitButton("Check $genre", enabled = !busy, onClick = { onGenre(genre) }) } @@ -46,7 +46,7 @@ internal fun AfterDarkReviewScreen(movies: List, approved: Set, late 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 } + fun billFor(choices: List, startMs: Long, week: Long): List? = choices.indices.asSequence().map { choices[Math.floorMod(week + it, choices.size.toLong()).toInt()] }.mapNotNull { bill -> + val films = bill.films.map { byTitle[seriesKey(it)] ?: return@mapNotNull null } var at = startMs films.takeIf { it.all { film -> val end = at + film.episodes.first().durationMs @@ -202,22 +205,31 @@ private fun rememberFilmPicture(session: EmbySampleSession, itemId: String?): Bi return picture } +/** One line on a board. Its height is fixed, whatever the font asks for, so the lines land where they're measured to. */ +@Composable +private fun BoardLine(height: Float, content: @Composable () -> Unit) { + Box(Modifier.fillMaxWidth().height(height.dp), contentAlignment = Alignment.Center) { content() } +} + /** 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) { +private fun MarqueeBoard(bill: DriveInBill?, nowMs: Long, x: Dp, y: Dp, width: Dp, height: Dp, big: Float, tilt: Float = 0f) { + val lineUp = Modifier.wrapContentHeight(unbounded = true) + Box(Modifier.offset(x, y).size(width, height).graphicsLayer(rotationZ = tilt), 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) + BoardLine(big * 1.2f) { Text(if (bill != null) "$head AT THE DRIVE-IN" else head, lineUp, 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)) + BoardLine(big * .72f) { + 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), lineUp, color = BoardInk, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = (big * .6f).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) + val title = bill.title.uppercase(java.util.Locale.UK) + val fit = when { title.length > 44 -> .42f; title.length > 32 -> .5f; else -> .62f } + BoardLine(big * .72f) { Text(title, lineUp.fillMaxWidth(), color = BoardInk, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = (big * fit).sp, textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis) } } } } @@ -286,6 +298,7 @@ private fun DriveInTicket(bill: DriveInBill, nowMs: Long, onClose: () -> Unit) { 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) + 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) @@ -333,16 +346,16 @@ internal fun DriveInScreen(session: EmbySampleSession, plan: ChannelPlan?, visit // 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) + 0 -> MarqueeBoard(selected, now, 270.dp, 192.dp, 430.dp, 90.dp, 29f) 1 -> { PictureOnBigScreen(picture) - MarqueeBoard(selected, now, 225.dp, 186.dp, 300.dp, 76.dp, 25f) + MarqueeBoard(selected, now, 217.dp, 183.dp, 300.dp, 82.dp, 24f, tilt = 1.5f) } 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) + Text(selected.title.uppercase(java.util.Locale.UK), color = Color.White, fontFamily = Marquee, fontSize = if (selected.title.length > 32) 18.sp else 24.sp, maxLines = 2, overflow = TextOverflow.Ellipsis) } } } 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 25b558f..0390d6d 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.67.0\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.68.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 9014292..1d2f6f1 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -98,7 +98,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea 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")) } + val scheduleStore = remember { java.io.File(appContext.noBackupFilesDir, "schedule").let { migrateChannelNumbers(it); ScheduleStore(it) } } val scheduler = remember { ChannelScheduler(scheduleStore) } var scheduledChannels by remember { mutableStateOf>(emptyMap()) } var suggesting by remember { mutableStateOf(null) } @@ -128,7 +128,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea seriesMatches = saved.matches.takeIf { it.isNotEmpty() }; approvedSeries = saved.approved episodeSamples = saved.episodes; channel = saved.orbitOne; toonamiChannel = saved.toonami; extraChannels = saved.extra darkMovies = saved.darkMovies; approvedDark = saved.approvedDark; afterDarkChannel = saved.afterDark - val available = availableSampleChannels(channel, afterDarkChannel?.let(::onAir), toonamiChannel?.let(::onAir), *extraChannels.map(::onAir).toTypedArray()) + val available = availableSampleChannels(channel?.let(::onAir), afterDarkChannel?.let(::onAir), toonamiChannel?.let(::onAir), *extraChannels.map(::onAir).toTypedArray()) selectedNumber = available.firstOrNull { it.number == saved.selectedNumber }?.number ?: available.firstOrNull()?.number ?: 101 onChannelAvailable(available.isNotEmpty()) } @@ -188,7 +188,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea // A problem here falls back to the loops. It must never count against the saved connection below. scheduledChannels = try { withContext(Dispatchers.IO) { - (saved.extra + listOfNotNull(saved.afterDark, saved.toonami)).mapNotNull { loop -> scheduler.load(saved.identity.userId, loop)?.also { resumed.rememberScheduledItems(it.plan.entries.map { entry -> entry.media.item }) } }.associateBy { it.number } + (saved.extra + listOfNotNull(saved.orbitOne, saved.afterDark, saved.toonami)).mapNotNull { loop -> scheduler.load(saved.identity.userId, loop)?.also { resumed.rememberScheduledItems(it.plan.entries.map { entry -> entry.media.item }) } }.associateBy { it.number } } } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { emptyMap() } session = resumed; sample = resumed.sample; applyChoices(saved) @@ -242,9 +242,9 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea } } LaunchedEffect(Unit) { while (true) { now = System.currentTimeMillis(); delay(1000) } } - fun smartLoop(programming: ChannelProgramming) = (extraChannels + listOfNotNull(afterDarkChannel, toonamiChannel)).firstOrNull { it.number == programming.number } + fun smartLoop(programming: ChannelProgramming) = (extraChannels + listOfNotNull(channel, afterDarkChannel, toonamiChannel)).firstOrNull { it.number == programming.number } // Keeps smart channels topped up in the background. A failure changes nothing on air. - LaunchedEffect(session, extraChannels, afterDarkChannel, toonamiChannel) { + LaunchedEffect(session, extraChannels, channel, afterDarkChannel, toonamiChannel) { val active = session ?: return@LaunchedEffect while (true) { for (programming in SmartChannels) { @@ -290,7 +290,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea val active = session ?: return scope.launch(Dispatchers.IO) { runCatching { scheduler.markFailed(active.accountId, itemId) } } } - val availableChannels = availableSampleChannels(channel, afterDarkChannel?.let(::onAir), toonamiChannel?.let(::onAir), *extraChannels.map(::onAir).toTypedArray()) + val availableChannels = availableSampleChannels(channel?.let(::onAir), afterDarkChannel?.let(::onAir), toonamiChannel?.let(::onAir), *extraChannels.map(::onAir).toTypedArray()) val liveChannel = availableChannels.firstOrNull { it.number == selectedNumber } if (mainDriveIn && liveSession != null) { val booked = driveInPlan @@ -438,7 +438,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea val context = coroutineContext active.buildAfterDarkChannel(approvedDark) { context.ensureActive() } } - afterDarkChannel = built; selectedNumber = 102; reviewingDark = false + afterDarkChannel = built; selectedNumber = AFTER_DARK_NUMBER; reviewingDark = false onChannelAvailable(true); onGuide() } catch (cancelled: CancellationException) { throw cancelled } catch (failure: EmbyCheckException) { message = failure.message } @@ -625,7 +625,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea val context = coroutineContext active.buildToonamiChannel { context.ensureActive() } } - toonamiChannel = built; selectedNumber = 103 + toonamiChannel = built; selectedNumber = VORTEX_NUMBER reviewingSeries = false; onChannelAvailable(true); onGuide() } catch (cancelled: CancellationException) { throw cancelled } catch (failure: EmbyCheckException) { message = failure.message } @@ -747,9 +747,9 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea } } }) - OrbitButton("102 Orbit After Dark — choose films", Modifier.fillMaxWidth(), + OrbitButton("$AFTER_DARK_NUMBER Orbit After Dark — choose films", Modifier.fillMaxWidth(), enabled = !busy && session != null, onClick = { message = null; reviewingDark = true }) - OrbitButton(if (seriesMatches == null) "103 Vortex — check shortlist" else "103 Vortex — review matches", + OrbitButton(if (seriesMatches == null) "$VORTEX_NUMBER Vortex — check shortlist" else "$VORTEX_NUMBER Vortex — review matches", Modifier.fillMaxWidth(), enabled = !busy && session != null, onClick = { if (seriesMatches != null) reviewingSeries = true else checkMoreSeries() }) diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt index 9f487ef..a5fc66d 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt @@ -153,7 +153,7 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v ScheduledMedia(item, plan.sourceId, duration) } checkActive(); currentToken() - val channel = SampleChannel(media, "Toonami", 103) + val channel = SampleChannel(media, "Toonami", VORTEX_NUMBER) channelEpisodes = channelEpisodes + ordered.associateBy { it.id } return channel } @@ -251,7 +251,7 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v ScheduledMedia(item, plan.sourceId, duration) } checkActive(); currentToken() - return SampleChannel(entries, "Orbit After Dark", 102).also { darkLineup = items.associateBy { it.id } } + return SampleChannel(entries, "Orbit After Dark", AFTER_DARK_NUMBER).also { darkLineup = items.associateBy { it.id } } } fun buildSampleChannel(): SampleChannel { diff --git a/android/app/src/main/java/tv/orbit/classic/RememberedState.kt b/android/app/src/main/java/tv/orbit/classic/RememberedState.kt index a926400..db4c451 100644 --- a/android/app/src/main/java/tv/orbit/classic/RememberedState.kt +++ b/android/app/src/main/java/tv/orbit/classic/RememberedState.kt @@ -28,7 +28,7 @@ internal object RememberedStateCodec { .put("user", i.userId).put("token", i.token ?: JSONObject.NULL).put("device", i.deviceId) .put("sample", arr(state.sample.items.map(::item))).put("total", state.sample.total ?: JSONObject.NULL) .put("matches", arr(state.matches.map { m -> JSONObject().put("requested", m.requested).put("more", m.moreAvailable).put("candidates", arr(m.candidates.map(::candidate))) })) - .put("approved", JSONArray(state.approved.toList())).put("selected", if (state.selectedNumber == 102) 101 else state.selectedNumber) + .put("approved", JSONArray(state.approved.toList())).put("selected", state.selectedNumber).put("numbering", 2) .put("selectedChannel", state.selectedNumber).put("afterDark", channel(state.afterDark)) .put("darkMovies", arr(state.darkMovies.map { movie -> JSONObject().put("item", item(movie.item)).put("year", movie.year ?: JSONObject.NULL) .put("rating", movie.rating).put("genres", JSONArray(movie.genres)) })) @@ -55,9 +55,10 @@ internal object RememberedStateCodec { } private fun readCandidate(o: JSONObject) = SeriesCandidate(id(o, "id"), text(o, "title", 160, false), if (o.isNull("year")) null else o.getInt("year").also { require(it in 1800..2200) }, text(o, "rating", 40), text(o, "variant", 40)) - private fun readChannel(o: JSONObject, key: String, number: Int): SampleChannel? { + /** Files from before the renumbering hold the old numbers, and are read as the new ones. */ + private fun readChannel(o: JSONObject, key: String, number: Int, oldNumber: Int = number): SampleChannel? { if (o.isNull(key)) return null - val c = o.getJSONObject(key); require(c.getInt("number") == number) + val c = o.getJSONObject(key); require(c.getInt("number") == if (o.optInt("numbering", 1) >= 2) number else oldNumber) return readEntries(c, number) } private fun readEntries(c: JSONObject, number: Int): SampleChannel { @@ -80,9 +81,9 @@ internal object RememberedStateCodec { if (e.isNull("season")) null else e.getInt("season").also { require(it >= 0) }, if (e.isNull("episode")) null else e.getInt("episode").also { require(it >= 0) }, duration(e), text(e, "media", 200)) }, group.getBoolean("more")) } - val one = readChannel(o, "one", 101); val toonami = readChannel(o, "toonami", 103) + val one = readChannel(o, "one", 101); val toonami = readChannel(o, "toonami", VORTEX_NUMBER, 103) require(one == null || one.lineup.all { m -> sample.items.any { it.id == m.item.id } }) - val dark = readChannel(o, "afterDark", 102) + val dark = readChannel(o, "afterDark", AFTER_DARK_NUMBER, 102) require(dark == null || (dark.lineup.size in 2..3 && dark.lineup.all { it.item.type == "Movie" })) val darkMovies = if (!o.has("darkMovies")) emptyList() else list(o, "darkMovies", 60).map { movie -> val media = readItem(movie.getJSONObject("item")); require(media.type == "Movie") @@ -97,11 +98,12 @@ internal object RememberedStateCodec { require(approvedDark.all { value -> darkMovies.any { it.item.id == value } }) // Absent in files saved before presets existed. val extra = if (!o.has("extra")) emptyList() else list(o, "extra", ChannelPresets.size).map { c -> - val number = c.getInt("number"); require(ChannelPresets.any { it.number == number }) + val number = c.getInt("number").let { if (o.optInt("numbering", 1) >= 2) it else OldChannelNumbers[it] ?: it }; require(ChannelPresets.any { it.number == number }) readEntries(c, number) } require(extra.map { it.number }.distinct().size == extra.size) - val selected = o.optInt("selectedChannel", o.getInt("selected")); require(selected in 101..103 || ChannelPresets.any { it.number == selected }) + val selected = o.optInt("selectedChannel", o.getInt("selected")).let { if (o.optInt("numbering", 1) >= 2) it else OldChannelNumbers[it] ?: it } + require(selected == VORTEX_NUMBER || selected == AFTER_DARK_NUMBER || ChannelPresets.any { it.number == selected }) return RememberedState(identity, sample, matches, approved, episodes, one, toonami, selected, dark, darkMovies, approvedDark, extra) } } diff --git a/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt b/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt index ed1fd5b..da8a853 100644 --- a/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt +++ b/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt @@ -5,6 +5,37 @@ import org.json.JSONObject import java.io.File import java.io.FileOutputStream +/** Old number to new, from when Orbit 2 and Orbit Three took 102 and 103. */ +internal val OldChannelNumbers = mapOf(102 to AFTER_DARK_NUMBER, 103 to VORTEX_NUMBER, 104 to 102, 112 to 103) + +/** + * Moves each channel's files to its new number, once, so schedules and places carry on untouched. New copies are finished + * before any old one goes, and the steps can be picked up again if the box loses power part way. + */ +internal fun migrateChannelNumbers(directory: File) { + val done = File(directory, "numbering-2") + if (done.exists()) return + directory.mkdirs() + val cleared = File(directory, "numbering-cleared") + if (!cleared.exists()) { + val pattern = Regex("""([a-z]+)-(\d+)(\.json(?:\.prev)?)""") + val old = directory.listFiles().orEmpty().mapNotNull { file -> pattern.matchEntire(file.name)?.let { match -> OldChannelNumbers[match.groupValues[2].toInt()]?.let { new -> Triple(file, match, new) } } } + for ((file, match, new) in old) { + val moving = File(directory, "${match.groupValues[1]}-$new${match.groupValues[3]}.moving") + if (moving.exists()) continue + val rewritten = runCatching { JSONObject(file.readText()).put("channel", new).toString() }.getOrNull() ?: continue + val part = File(directory, "${moving.name}.part") + part.writeText(rewritten) + part.renameTo(moving) + } + old.forEach { (file) -> file.delete() } + cleared.createNewFile() + } + directory.listFiles().orEmpty().filter { it.name.endsWith(".moving") }.forEach { it.renameTo(File(directory, it.name.removeSuffix(".moving"))) } + done.createNewFile() + cleared.delete() +} + /** * Three small files per channel in app-private storage, away from the library database: the episode cache, * the committed schedule and the aired progress. Each can be lost without affecting the others. Holds no login. 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 1a5277c..d077b36 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.67.0") + next.setRequestProperty("User-Agent", "OrbitTV/0.68.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/java/tv/orbit/classic/SeriesReviewScreen.kt b/android/app/src/main/java/tv/orbit/classic/SeriesReviewScreen.kt index 7ec22ac..e53793c 100644 --- a/android/app/src/main/java/tv/orbit/classic/SeriesReviewScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/SeriesReviewScreen.kt @@ -21,7 +21,7 @@ internal fun SeriesReviewScreen(matches: List, approved: Set, approved: Set ScheduledMedia(SampleItem("Fixture episode", "Episode", 24, "mkv", "ep-$i", 1_440_000), "ep-$i-source", 1_440_000) }, "Toonami", 103) - val dark = SampleChannel(one.lineup, "Orbit After Dark", 102) + val toonami = SampleChannel(List(2) { i -> ScheduledMedia(SampleItem("Fixture episode", "Episode", 24, "mkv", "ep-$i", 1_440_000), "ep-$i-source", 1_440_000) }, "Toonami", 120) + val dark = SampleChannel(one.lineup, "Orbit After Dark", 130) val saved = RememberedState(RememberedIdentity("fixture-user", "fixture-token", "fixture-device"), EmbySample(movies.map { it.item }, 2), - emptyList(), emptySet(), null, one, toonami, 102, dark, movies, setOf("one", "two")) + emptyList(), emptySet(), null, one, toonami, 130, dark, movies, setOf("one", "two")) val restored = RememberedStateCodec.decode(RememberedStateCodec.encode(saved)) val time = 1_793_491_234_567L assertEquals(one.guide(time), restored.orbitOne!!.guide(time)) assertEquals(toonami.guide(time), restored.toonami!!.guide(time)) assertEquals(dark.guide(time), restored.afterDark!!.guide(time)) - assertEquals(listOf(101, 102, 103), availableSampleChannels(restored.toonami, restored.afterDark, restored.orbitOne, null, restored.orbitOne).map { it.number }) + assertEquals(listOf(101, 120, 130), availableSampleChannels(restored.toonami, restored.afterDark, restored.orbitOne, null, restored.orbitOne).map { it.number }) } } diff --git a/android/app/src/test/java/tv/orbit/classic/ChannelPresetsTest.kt b/android/app/src/test/java/tv/orbit/classic/ChannelPresetsTest.kt index 07ba90f..ab231b9 100644 --- a/android/app/src/test/java/tv/orbit/classic/ChannelPresetsTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/ChannelPresetsTest.kt @@ -106,7 +106,7 @@ class ChannelPresetsTest { } @Test fun theNewChannelsAreConfiguredAndBranded() { - listOf(111, 112, 113, 121, 122, 123, 124, 141, 142).forEach { number -> + listOf(111, 103, 113, 121, 122, 123, 124, 141, 142).forEach { number -> assertTrue(SmartChannels.any { it.number == number && it.exactTitles && it.finishTogether }) assertNotNull(ChannelBrands.forNumber(number)) assertTrue(ChannelPresets.first { it.number == number }.smartOnly) @@ -157,13 +157,13 @@ class ChannelPresetsTest { } @Test fun theLastChannelsAreConfiguredAndBranded() { - listOf(104, 126, 127, 131, 132, 133, 134, 135, 136, 144, 150).forEach { number -> + listOf(102, 126, 127, 131, 132, 133, 134, 135, 136, 144, 150).forEach { number -> assertNotNull(ChannelBrands.forNumber(number)) assertTrue(SmartChannels.any { it.number == number && it.exactTitles }) } listOf(126, 127, 131, 132, 133, 134, 135, 136).forEach { assertNotNull(SmartChannels.first { channel -> channel.number == it }.filmRestMs) } assertEquals(ChannelCategory.Sport, ChannelBrands.forNumber(150)!!.category) - assertEquals(28, SmartChannels.size) + assertEquals(29, SmartChannels.size) } @Test fun ringXKeepsItsWeeknightsAndFallsBackToTheVault() { @@ -178,4 +178,53 @@ class ChannelPresetsTest { // No Impact in this library, so Tuesday isn't left empty. assertEquals(3, dayPartPool(pool, ringX.dayParts, on(3), london).size) } + + @Test fun orbitOneTwoAndThreeHoldTheFirstNumbers() { + assertEquals("Orbit 1", ChannelBrands.forNumber(101)!!.displayName) + assertEquals("Orbit 2", ChannelBrands.forNumber(102)!!.displayName) + assertEquals("Orbit Three", ChannelBrands.forNumber(103)!!.displayName) + assertEquals("Vortex", ChannelBrands.forNumber(VORTEX_NUMBER)!!.displayName) + assertEquals("Orbit After Dark", ChannelBrands.forNumber(AFTER_DARK_NUMBER)!!.displayName) + assertTrue(SmartChannels.any { it.number == 101 }) + } + + private fun schedule(folder: java.io.File, kind: String, number: Int, marker: String, suffix: String = ".json") = + java.io.File(folder, "$kind-$number$suffix").writeText("""{"schema":1,"user":"u","channel":$number,"marker":"$marker"}""") + private fun read(folder: java.io.File, name: String) = org.json.JSONObject(java.io.File(folder, name).readText()) + + @Test fun channelFilesMoveToTheirNewNumbersOnce() { + val folder = java.nio.file.Files.createTempDirectory("numbers").toFile() + schedule(folder, "schedule", 102, "after dark"); schedule(folder, "schedule", 104, "orbit 2"); schedule(folder, "schedule", 103, "vortex", ".json.prev") + schedule(folder, "progress", 112, "three"); schedule(folder, "schedule", 140, "terra") + migrateChannelNumbers(folder) + assertEquals("after dark", read(folder, "schedule-$AFTER_DARK_NUMBER.json").getString("marker")) + assertEquals(AFTER_DARK_NUMBER, read(folder, "schedule-$AFTER_DARK_NUMBER.json").getInt("channel")) + assertEquals("orbit 2", read(folder, "schedule-102.json").getString("marker")) + assertEquals(102, read(folder, "schedule-102.json").getInt("channel")) + assertEquals("vortex", read(folder, "schedule-$VORTEX_NUMBER.json.prev").getString("marker")) + assertEquals("three", read(folder, "progress-103.json").getString("marker")) + assertEquals("terra", read(folder, "schedule-140.json").getString("marker")) + assertFalse(java.io.File(folder, "schedule-104.json").exists()) + // A second run changes nothing, even with a channel now on an old number. + migrateChannelNumbers(folder) + assertEquals("orbit 2", read(folder, "schedule-102.json").getString("marker")) + } + + @Test fun aMoveInterruptedPartWayFinishesWithoutLosingAFile() { + val folder = java.nio.file.Files.createTempDirectory("numbers").toFile() + schedule(folder, "schedule", 104, "orbit 2") + // Power went after the new copy was made, and the old After Dark file was already gone. + java.io.File(folder, "schedule-$AFTER_DARK_NUMBER.json.moving").writeText("""{"channel":$AFTER_DARK_NUMBER,"marker":"after dark"}""") + migrateChannelNumbers(folder) + assertEquals("after dark", read(folder, "schedule-$AFTER_DARK_NUMBER.json").getString("marker")) + assertEquals("orbit 2", read(folder, "schedule-102.json").getString("marker")) + } + + @Test fun anEmptyBoxNeverMovesItsLaterFiles() { + val folder = java.io.File(java.nio.file.Files.createTempDirectory("numbers").toFile(), "schedule") + migrateChannelNumbers(folder) + schedule(folder, "schedule", 102, "orbit 2") + migrateChannelNumbers(folder) + assertEquals("orbit 2", read(folder, "schedule-102.json").getString("marker")) + } } diff --git a/android/app/src/test/java/tv/orbit/classic/ChannelScheduleTest.kt b/android/app/src/test/java/tv/orbit/classic/ChannelScheduleTest.kt index 7621881..9214cba 100644 --- a/android/app/src/test/java/tv/orbit/classic/ChannelScheduleTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/ChannelScheduleTest.kt @@ -535,7 +535,7 @@ class ChannelScheduleTest { } @Test fun vortexKeepsSubtitledShowsAndCartoonChannelsWantEnglish() { - assertFalse(SmartChannels.first { it.number == 103 }.englishAudio) + assertFalse(SmartChannels.first { it.number == VORTEX_NUMBER }.englishAudio) assertTrue(SmartChannels.first { it.number == 123 }.englishAudio) } diff --git a/android/app/src/test/java/tv/orbit/classic/ChannelSuggestionsTest.kt b/android/app/src/test/java/tv/orbit/classic/ChannelSuggestionsTest.kt index 5103de4..64fea76 100644 --- a/android/app/src/test/java/tv/orbit/classic/ChannelSuggestionsTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/ChannelSuggestionsTest.kt @@ -82,12 +82,12 @@ class ChannelSuggestionsTest { } @Test fun afterDarkAndVortexBlocksAcceptTheirGroups() { - assertEquals("day", SmartChannels.first { it.number == 102 }.dayParts[1].group) - assertEquals(listOf("movie", "day", "night"), SmartChannels.first { it.number == 103 }.dayParts.map { it.group }) + assertEquals("day", SmartChannels.first { it.number == AFTER_DARK_NUMBER }.dayParts[1].group) + assertEquals(listOf("movie", "day", "night"), SmartChannels.first { it.number == VORTEX_NUMBER }.dayParts.map { it.group }) } @Test fun vortexKeepsPaulsToonamiPicksInTheRightBlocks() { - val vortex = SmartChannels.first { it.number == 103 } + val vortex = SmartChannels.first { it.number == VORTEX_NUMBER } val day = vortex.dayParts.first { it.group == "day" }.titles.map(::seriesKey) assertTrue(listOf("Batman Beyond", "Batman: The Animated Series", "ThunderCats", "The Powerpuff Girls", "Jonny Quest").all { seriesKey(it) in day }) assertTrue(seriesKey("Dragon Ball Z") in vortex.dayParts.first { it.group == "night" }.titles.map(::seriesKey)) @@ -112,7 +112,7 @@ class ChannelSuggestionsTest { } @Test fun vortexOnlyTakesExactTitles() { - assertTrue(SmartChannels.first { it.number == 103 }.exactTitles) + assertTrue(SmartChannels.first { it.number == VORTEX_NUMBER }.exactTitles) } private val london = java.time.ZoneId.of("Europe/London") diff --git a/android/app/src/test/java/tv/orbit/classic/DriveInTest.kt b/android/app/src/test/java/tv/orbit/classic/DriveInTest.kt index 84ef772..0f1fe57 100644 --- a/android/app/src/test/java/tv/orbit/classic/DriveInTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/DriveInTest.kt @@ -99,4 +99,12 @@ class DriveInTest { @Test fun theDriveInIsBranded() { assertEquals("Orbit Drive-In", ChannelBrands.forNumber(DRIVE_IN_NUMBER)!!.displayName) } + + @Test fun aBillHasItsOwnNameAndAFilmKeepsItsTitle() { + val plan = planDriveIn(null, big, late, bills, at(2, 10), london)!! + val all = driveInBills(plan, london) + assertEquals("The Back to the Future Trilogy", all.first { it.kind == DriveInKind.Triple }.title) + assertEquals("Alien and Aliens", all.first { it.kind == DriveInKind.Double }.title) + assertTrue(all.first { it.kind == DriveInKind.Marquee }.title.startsWith("Big")) + } } diff --git a/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt b/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt index 0cbf854..8766258 100644 --- a/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt @@ -23,7 +23,7 @@ class GuideGridTest { } } @Test fun aLongFilmIsClippedToTheWindow() { - val longOne = SampleChannel(listOf(media("x", 600), media("y", 600)), "Fixture", 102) + val longOne = SampleChannel(listOf(media("x", 600), media("y", 600)), "Fixture", 130) val cells = guideCells(longOne, start, start + GUIDE_SLOT_MS) assertEquals(1, cells.size); assertEquals(1f, cells[0].widthFraction, .001f) } @@ -53,17 +53,17 @@ class GuideGridTest { @Test fun homeOnlyListsCategoriesWithChannels() { - assertEquals(listOf(ChannelCategory.All, ChannelCategory.Entertainment, ChannelCategory.Movies), homeCategories(listOf(101, 102, 103))) - assertEquals(listOf(ChannelCategory.All, ChannelCategory.Movies), homeCategories(listOf(102))) + assertEquals(listOf(ChannelCategory.All, ChannelCategory.Entertainment, ChannelCategory.Movies), homeCategories(listOf(101, 130, 120))) + assertEquals(listOf(ChannelCategory.All, ChannelCategory.Movies), homeCategories(listOf(130))) assertEquals(listOf(ChannelCategory.All), homeCategories(listOf(999))) } @Test fun aCategoryOpensOnlyItsOwnChannels() { - val dark = SampleChannel(listOf(media("a", 20), media("b", 20)), "Fixture", 102) - val vortex = SampleChannel(listOf(media("c", 20), media("d", 20)), "Fixture", 103) - assertEquals(listOf(102), channelsIn(ChannelCategory.Movies, listOf(dark, vortex)).map { it.number }) - assertEquals(listOf(102, 103), channelsIn(ChannelCategory.All, listOf(dark, vortex)).map { it.number }) + val dark = SampleChannel(listOf(media("a", 20), media("b", 20)), "Fixture", 130) + val vortex = SampleChannel(listOf(media("c", 20), media("d", 20)), "Fixture", 120) + assertEquals(listOf(130), channelsIn(ChannelCategory.Movies, listOf(dark, vortex)).map { it.number }) + assertEquals(listOf(130, 120), channelsIn(ChannelCategory.All, listOf(dark, vortex)).map { it.number }) } @Test diff --git a/android/app/src/test/java/tv/orbit/classic/RememberedStateTest.kt b/android/app/src/test/java/tv/orbit/classic/RememberedStateTest.kt index 07e3032..ef955ca 100644 --- a/android/app/src/test/java/tv/orbit/classic/RememberedStateTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/RememberedStateTest.kt @@ -10,9 +10,9 @@ class RememberedStateTest { private fun state(): RememberedState { val series = SeriesCandidate("fixture-series", "Fixture series", 1996, "TV-PG") val episodes = SeriesEpisodes(series, List(2) { EpisodeCandidate("episode-$it", "Fixture $it", 1, it + 1, 60_000) }, false) - val channel = SampleChannel(episodes.episodes.map { ScheduledMedia(episodeSampleItem(series, it), "source-${it.id}", 60_000) }, "Toonami", 103) + val channel = SampleChannel(episodes.episodes.map { ScheduledMedia(episodeSampleItem(series, it), "source-${it.id}", 60_000) }, "Toonami", 120) return RememberedState(RememberedIdentity("fixture-user", "fixture-token", "fixture-device"), EmbySample(emptyList(), 0), - listOf(SeriesMatches(ToonamiSearches.first(), listOf(series), false)), setOf(series.id), listOf(episodes), null, channel, 103) + listOf(SeriesMatches(ToonamiSearches.first(), listOf(series), false)), setOf(series.id), listOf(episodes), null, channel, 120) } private fun key() = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() @Test fun encryptedRoundTripPreservesChoicesAndAbsoluteTimeline() { @@ -23,7 +23,7 @@ class RememberedStateTest { val restored = RememberedStateCodec.decode(ConnectionCipher.open(key, sealed)) assertEquals(original.approved, restored.approved); assertEquals("fixture-token", restored.identity.token) assertEquals(original.toonami!!.guide(1_793_491_234_567L), restored.toonami!!.guide(1_793_491_234_567L)) - assertEquals(103, restored.selectedNumber); assertEquals(2, restored.episodes!!.single().episodes.size) + assertEquals(120, restored.selectedNumber); assertEquals(2, restored.episodes!!.single().episodes.size) assertFalse(restored.toString().contains("fixture-token")); assertFalse(restored.identity.toString().contains("fixture-token")) } @Test fun freshNoncesProduceDifferentCiphertextAndTamperingIsRejected() { @@ -46,7 +46,7 @@ class RememberedStateTest { } @Test fun expiredTokenCanBeRemovedWithoutLosingChannelChoices() { val noToken = RememberedStateCodec.decode(RememberedStateCodec.encode(state().withoutToken())) - assertNull(noToken.identity.token); assertEquals(103, noToken.toonami!!.number) + assertNull(noToken.identity.token); assertEquals(120, noToken.toonami!!.number) assertEquals(setOf("fixture-series"), noToken.approved) } @Test fun resumesByValidatingSavedUserWithoutAuthenticatingOrRefreshingSchedule() { @@ -92,4 +92,22 @@ class RememberedStateTest { assertFalse(requests.any { it.path == "/Sessions/Logout" }) } + + @Test fun aFileFromBeforeTheRenumberingReadsAsTheNewNumbers() { + val movie = ScheduledMedia(SampleItem("Film", "Movie", 90, "mkv", "film-a", 5_400_000), "source-a", 5_400_000) + val other = ScheduledMedia(SampleItem("Film 2", "Movie", 90, "mkv", "film-b", 5_400_000), "source-b", 5_400_000) + val state = RememberedState(RememberedIdentity("fixture-user", "fixture-token", "fixture-device"), EmbySample(emptyList(), null), emptyList(), emptySet(), null, null, + SampleChannel(listOf(movie, other), "Toonami", VORTEX_NUMBER), 103, SampleChannel(listOf(movie, other), "Orbit After Dark", AFTER_DARK_NUMBER), extra = listOf(SampleChannel(listOf(movie, other), "Orbit 2", 102))) + val old = org.json.JSONObject(RememberedStateCodec.encode(state).toString(Charsets.UTF_8)) + old.remove("numbering") + old.getJSONObject("toonami").put("number", 103); old.getJSONObject("afterDark").put("number", 102); old.getJSONArray("extra").getJSONObject(0).put("number", 104) + old.put("selectedChannel", 102) + val read = RememberedStateCodec.decode(old.toString().toByteArray()) + assertEquals(VORTEX_NUMBER, read.toonami!!.number) + assertEquals(AFTER_DARK_NUMBER, read.afterDark!!.number) + assertEquals(listOf(102), read.extra.map { it.number }) + assertEquals(AFTER_DARK_NUMBER, read.selectedNumber) + val again = RememberedStateCodec.decode(RememberedStateCodec.encode(read)) + assertEquals(AFTER_DARK_NUMBER, again.selectedNumber); assertEquals(listOf(102), again.extra.map { it.number }) + } } diff --git a/android/app/src/test/java/tv/orbit/classic/ToonamiChannelTest.kt b/android/app/src/test/java/tv/orbit/classic/ToonamiChannelTest.kt index 9c194eb..b97ae58 100644 --- a/android/app/src/test/java/tv/orbit/classic/ToonamiChannelTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/ToonamiChannelTest.kt @@ -22,7 +22,7 @@ class ToonamiChannelTest { } @Test fun fullFifteenEpisodeSampleUsesClockBoundariesAndRepeats() { val ordered = toonamiEpisodeOrder(listOf(group("a", 5), group("b", 5), group("c", 5))) - val channel = SampleChannel(ordered.map { ScheduledMedia(it, "${it.id}-source", 60_000) }, "Toonami", 103) + val channel = SampleChannel(ordered.map { ScheduledMedia(it, "${it.id}-source", 60_000) }, "Toonami", 120) assertEquals(15, channel.lineup.size); assertEquals("b-1", channel.at(60_000).media.item.id) assertEquals("a-1", channel.at(channel.cycleMs).media.item.id) assertEquals(12_345L, channel.at(72_345).offsetAt(72_345)) @@ -55,7 +55,7 @@ class ToonamiChannelTest { assertTrue(fake.requests.isEmpty()) session.checkSeriesEpisodes(listOf(group("a").series, group("b").series)) val channel = session.buildToonamiChannel() - assertEquals(103, channel.number); assertEquals("Toonami", channel.name) + assertEquals(120, channel.number); assertEquals("Toonami", channel.name) assertEquals(listOf("a-1", "b-1", "a-2", "b-2"), channel.lineup.map { it.item.id }) assertEquals(60_123L, channel.lineup.first().durationMs) val probes = fake.requests.filter { it.path.endsWith("/PlaybackInfo") } diff --git a/design/interface/build-new-icons.py b/design/interface/build-new-icons.py index 57924e5..78a9c4f 100644 --- a/design/interface/build-new-icons.py +++ b/design/interface/build-new-icons.py @@ -31,9 +31,18 @@ for name, body in ICON.items(): SILVER = '' BG = '' MARK = f'' -png = render(f'{SILVER}{BG}{MARK}', 'orbit_app_icon.svg') -Image.open(png).convert('RGB').resize((512, 512), Image.LANCZOS).save(RES / 'orbit_app_icon.webp', quality=92) -png = render(f'{SILVER}{BG}{MARK}ORBIT', 'orbit_banner.svg') -Image.open(png).convert('RGB').resize((640, 360), Image.LANCZOS).save(RES / 'orbit_banner.png') +def centred(name, w, h, background, art, fill): + """Lays art on the background with its visible edges centred, as launchers show the picture exactly as drawn.""" + back = Image.open(render(f'{BG}{background}', f'{name}_back.svg')).convert('RGBA') + front = Image.open(render(f'{SILVER}{art}', f'{name}.svg')).convert('RGBA') + front = front.crop(front.getbbox()) + scale = min(w * fill / front.width, h * fill / front.height) + front = front.resize((round(front.width * scale), round(front.height * scale)), Image.LANCZOS) + back.paste(front, ((w - front.width) // 2, (h - front.height) // 2), front) + (ICONS / f'{name}_back.svg').unlink(); (ICONS / f'{name}_back.png').unlink() + return back.convert('RGB') +NIGHT = '' +centred('orbit_app_icon', 512, 512, NIGHT, f'{MARK}', .72).save(RES / 'orbit_app_icon.webp', quality=92) +centred('orbit_banner', 640, 360, NIGHT, f'{MARK}ORBIT', .78).save(RES / 'orbit_banner.png') for f in ICONS.glob('*_v2.png'): f.unlink() for n in ('orbit_app_icon', 'orbit_banner'): (ICONS / f'{n}.png').unlink(missing_ok=True) diff --git a/design/interface/icons/orbit_app_icon.svg b/design/interface/icons/orbit_app_icon.svg index 8d7773a..52fa494 100644 --- a/design/interface/icons/orbit_app_icon.svg +++ b/design/interface/icons/orbit_app_icon.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/design/interface/icons/orbit_banner.svg b/design/interface/icons/orbit_banner.svg index 27762a7..2ddba3e 100644 --- a/design/interface/icons/orbit_banner.svg +++ b/design/interface/icons/orbit_banner.svg @@ -1 +1 @@ -ORBIT \ No newline at end of file +ORBIT \ No newline at end of file diff --git a/docs/roadmap.md b/docs/roadmap.md index b2c482f..dfed121 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -100,7 +100,7 @@ Paul's ideas, 1 November 2026. Not fixes and not scheduled. ## Channel numbers -Paul's direction, 1 November 2026: 101, 102 and 103 should become Orbit 1, Orbit 2 and Orbit Three. Today 102 is After Dark and 103 is Vortex, so they'll move when the generic channel list arrives, with a tested migration. Other numbers aren't mapped yet. +Done in 0.68: 101, 102 and 103 are Orbit 1, Orbit 2 and Orbit Three, as Paul asked. Vortex moved to 120 and After Dark to 130, at the head of their blocks. Other numbers are still placeholders. ## Interface direction