Give Orbit 1, 2 and Three 101 to 103, make Orbit 1 smart, fix Drive-In boards and centre the icon (0.68.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m38s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m28s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-02 12:22:35 +00:00
co-authored by Claude Opus 5.5
parent 089b934e48
commit 20bac5f95e
28 changed files with 248 additions and 102 deletions
+10
View File
@@ -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.
+2 -2
View File
@@ -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 }
@@ -23,7 +23,7 @@ internal fun AfterDarkReviewScreen(movies: List<AfterDarkMovie>, approved: Set<S
Box(modifier.fillMaxWidth().background(Navy)) {
Image(painterResource(R.drawable.orbit_after_dark_background), null, Modifier.matchParentSize(), contentScale = ContentScale.Crop, alpha = .45f)
Column(Modifier.fillMaxSize().padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Image(painterResource(R.drawable.orbit_after_dark_epg), "102 Orbit After Dark", Modifier.width(230.dp).height(65.dp))
Image(painterResource(R.drawable.orbit_after_dark_epg), "$AFTER_DARK_NUMBER Orbit After Dark", Modifier.width(230.dp).height(65.dp))
Text("Late-night and cult cinema. Check a category, then choose two or three films.", color = Color.White, fontSize = 17.sp)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AfterDarkGenres.forEach { genre -> OrbitButton("Check $genre", enabled = !busy, onClick = { onGenre(genre) }) }
@@ -46,7 +46,7 @@ internal fun AfterDarkReviewScreen(movies: List<AfterDarkMovie>, approved: Set<S
}
Text("${approved.size}/3 selected • Existing schedules change only when you create or rebuild this channel.", color = Muted, fontSize = 13.sp)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OrbitButton("Create / rebuild 102", enabled = !busy && approved.size in 2..3, onClick = onBuild)
OrbitButton("Create / rebuild $AFTER_DARK_NUMBER", enabled = !busy && approved.size in 2..3, onClick = onBuild)
OrbitButton("Back", onClick = onBack)
}
}
@@ -5,17 +5,17 @@ internal data class ChannelBranding(val presetId: String, val displayName: Strin
internal object ChannelBrands {
private val brands = mapOf(
101 to ChannelBranding("orbit-1", "Orbit 1", R.drawable.orbit_one_epg, R.drawable.orbit_one_background, R.drawable.orbit_one_bug, ChannelCategory.Entertainment),
102 to ChannelBranding("orbit-after-dark", "Orbit After Dark", R.drawable.orbit_after_dark_epg, R.drawable.orbit_after_dark_background, R.drawable.orbit_after_dark_bug, ChannelCategory.Movies),
103 to ChannelBranding("vortex", "Vortex", R.drawable.vortex_epg, R.drawable.vortex_background, R.drawable.vortex_bug, ChannelCategory.Entertainment),
AFTER_DARK_NUMBER to ChannelBranding("orbit-after-dark", "Orbit After Dark", R.drawable.orbit_after_dark_epg, R.drawable.orbit_after_dark_background, R.drawable.orbit_after_dark_bug, ChannelCategory.Movies),
VORTEX_NUMBER to ChannelBranding("vortex", "Vortex", R.drawable.vortex_epg, R.drawable.vortex_background, R.drawable.vortex_bug, ChannelCategory.Entertainment),
111 to ChannelBranding("orbit-gold", "Orbit Gold", R.drawable.orbit_gold_epg, R.drawable.orbit_gold_background, R.drawable.orbit_gold_bug, ChannelCategory.Comedy),
112 to ChannelBranding("orbit-three", "Orbit Three", R.drawable.orbit_three_epg, R.drawable.orbit_three_background, R.drawable.orbit_three_bug, ChannelCategory.Comedy),
103 to ChannelBranding("orbit-three", "Orbit Three", R.drawable.orbit_three_epg, R.drawable.orbit_three_background, R.drawable.orbit_three_bug, ChannelCategory.Comedy),
113 to ChannelBranding("orbit-riot", "Orbit Riot", R.drawable.orbit_riot_epg, R.drawable.orbit_riot_background, R.drawable.orbit_riot_bug, ChannelCategory.Comedy),
142 to ChannelBranding("orbit-dark-files", "Orbit Dark Files", R.drawable.orbit_dark_files_epg, R.drawable.orbit_dark_files_background, R.drawable.orbit_dark_files_bug, ChannelCategory.Documentaries),
114 to ChannelBranding("orbit-detour", "Orbit Detour", R.drawable.orbit_detour_epg, R.drawable.orbit_one_background, R.drawable.orbit_detour_bug, ChannelCategory.Movies),
115 to ChannelBranding("orbit-britx", "Orbit BritX", R.drawable.orbit_britx_epg, R.drawable.orbit_one_background, R.drawable.orbit_britx_bug, ChannelCategory.Movies),
125 to ChannelBranding("orbit-junior", "Orbit Junior", R.drawable.orbit_junior_epg, R.drawable.orbit_one_background, R.drawable.orbit_junior_bug, ChannelCategory.Kids),
143 to ChannelBranding("orbit-wild", "Orbit Wild", R.drawable.orbit_wild_epg, R.drawable.orbit_one_background, R.drawable.orbit_wild_bug, ChannelCategory.Documentaries),
104 to ChannelBranding("orbit-2", "Orbit 2", R.drawable.orbit_2_epg, R.drawable.orbit_one_background, R.drawable.orbit_2_bug, ChannelCategory.Entertainment),
102 to ChannelBranding("orbit-2", "Orbit 2", R.drawable.orbit_2_epg, R.drawable.orbit_one_background, R.drawable.orbit_2_bug, ChannelCategory.Entertainment),
126 to ChannelBranding("wonder", "Orbit Wonder", R.drawable.wonder_epg, R.drawable.orbit_one_background, R.drawable.wonder_bug, ChannelCategory.Kids),
127 to ChannelBranding("orbit-pixelz", "Orbit PixelZ", R.drawable.orbit_pixelz_epg, R.drawable.orbit_one_background, R.drawable.orbit_pixelz_bug, ChannelCategory.Kids),
131 to ChannelBranding("orbit-vhs", "Orbit VHS", R.drawable.orbit_vhs_epg, R.drawable.orbit_one_background, R.drawable.orbit_vhs_bug, ChannelCategory.Movies),
@@ -20,13 +20,19 @@ internal val RingXImpact = listOf("TNA iMPACT!", "TNA Impact!", "TNA Impact Wres
internal val RingXAew = listOf("AEW Dynamite", "All Elite Wrestling: Dynamite", "AEW Rampage", "All Elite Wrestling: Rampage", "AEW Collision", "AEW Dark")
internal val RingXVault = listOf("WWE SmackDown", "WWF SmackDown", "WWE Friday Night SmackDown", "ECW Hardcore TV", "ECW on TNN", "WWE ECW", "WCW Thunder", "WCW Saturday Night", "WWE NXT", "WWF Superstars", "WWF Attitude", "WrestleMania", "WWE WrestleMania", "WWE Pay-Per-View", "WWF Pay-Per-View", "WCW Pay-Per-View", "ECW Pay-Per-View", "The Monday Night War", "Dark Side of the Ring", "WWE Legends' House")
/** Orbit 1, 2 and Three hold 101 to 103, as Paul asked. Vortex and After Dark moved to the head of their blocks. */
internal const val VORTEX_NUMBER = 120
internal const val AFTER_DARK_NUMBER = 130
internal val ChannelPresets = listOf(
ChannelPreset(101, "Orbit 1", listOf("Frasier", "Cheers", "The Fresh Prince of Bel-Air", "Home Improvement", "Malcolm in the Middle", "Everybody Loves Raymond", "The Big Bang Theory", "Modern Family", "My Wife and Kids", "Sister, Sister", "Sabrina the Teenage Witch",
"Saved by the Bell", "Boy Meets World", "The Wonder Years", "Full House", "Family Matters", "The Golden Girls", "Happy Days", "Last of the Summer Wine", "My Family", "Outnumbered", "Not Going Out", "Miranda", "Mrs. Brown's Boys", "Goodnight Sweetheart", "Bread", "Red Dwarf", "Young Sheldon", "The King of Queens", "Two and a Half Men"), smartOnly = true),
ChannelPreset(140, "Terra", listOf("Blue Planet", "Planet Earth", "Frozen Planet", "Life", "Our Planet")),
ChannelPreset(152, "Paradox", listOf("X-Files", "Buffy the Vampire Slayer", "Supernatural", "Fringe", "Doctor Who")),
ChannelPreset(111, "Orbit Gold", listOf("Only Fools and Horses", "Father Ted", "Blackadder", "The Black Adder", "Fawlty Towers", "Ghosts", "The Good Life", "The Green Green Grass",
"The Vicar of Dibley", "Porridge", "Taskmaster", "8 Out of 10 Cats", "Dad's Army", "Yes Minister", "Keeping Up Appearances", "Open All Hours", "Rising Damp", "The Likely Lads", "Steptoe and Son", "Hi-de-Hi!", "'Allo 'Allo!",
"Are You Being Served?", "Birds of a Feather", "Men Behaving Badly", "The IT Crowd", "Peep Show"), smartOnly = true),
ChannelPreset(112, "Orbit Three", listOf("Still Game", "Limmy's Show", "The Mighty Boosh", "Two Pints of Lager and a Packet of Crisps", "Gary: Tank Commander", "Family Guy", "Gavin & Stacey", "Little Britain",
ChannelPreset(103, "Orbit Three", listOf("Still Game", "Limmy's Show", "The Mighty Boosh", "Two Pints of Lager and a Packet of Crisps", "Gary: Tank Commander", "Family Guy", "Gavin & Stacey", "Little Britain",
"The Inbetweeners", "Bad Education", "Burnistoun", "Rab C. Nesbitt", "Chewin' the Fat", "Phoenix Nights", "Him & Her", "Spaced", "Black Books", "Fresh Meat", "Uncle", "Scot Squad"), smartOnly = true),
ChannelPreset(113, "Riot", listOf("South Park", "The Simpsons", "It's Always Sunny in Philadelphia", "Trailer Park Boys", "Friends", "How I Met Your Mother", "Futurama", "American Dad!", "Bob's Burgers",
"King of the Hill", "Rick and Morty", "Beavis and Butt-Head", "Archer", "BoJack Horseman", "Seinfeld", "Scrubs", "The Office", "Arrested Development", "Parks and Recreation", "Curb Your Enthusiasm"), smartOnly = true),
@@ -57,7 +63,7 @@ internal val ChannelPresets = listOf(
"Pingu in the City", "Peppa Pig", "Bluey", "Bagpuss", "The Clangers", "Camberwick Green", "Trumpton", "Button Moon", "Rainbow", "Balamory", "Fimbles"), smartOnly = true),
ChannelPreset(143, "Orbit Wild", listOf("The Crocodile Hunter", "Crocodile Hunter Diaries", "Deadliest Catch", "River Monsters", "Man vs. Wild", "Running Wild with Bear Grylls", "Survivorman", "Dual Survival", "Naked and Afraid", "Ice Road Truckers",
"Swamp People", "Wild Things with Dominic Monaghan", "Lone Star Law", "Alone", "Ax Men", "Monster Fish", "Dirty Jobs", "Shark Week", "Whale Wars", "Dangerous Encounters with Brady Barr"), smartOnly = true),
ChannelPreset(104, "Orbit 2", listOf("Twin Peaks", "Quantum Leap", "The Twilight Zone", "The Outer Limits", "Tales from the Crypt", "Eerie, Indiana", "Are You Afraid of the Dark?", "Goosebumps", "Sliders", "Knight Rider", "The A-Team", "Airwolf", "MacGyver",
ChannelPreset(102, "Orbit 2", listOf("Twin Peaks", "Quantum Leap", "The Twilight Zone", "The Outer Limits", "Tales from the Crypt", "Eerie, Indiana", "Are You Afraid of the Dark?", "Goosebumps", "Sliders", "Knight Rider", "The A-Team", "Airwolf", "MacGyver",
"Red Dwarf", "The Crystal Maze", "Robot Wars", "Gladiators", "Takeshi's Castle", "Gadget Show", "Top Gear", "Time Team", "Scrapheap Challenge", "Countdown", "Have I Got News for You", "QI", "Would I Lie to You?", "Pointless", "Mastermind", "University Challenge", "Mythbusters", "How It's Made"), smartOnly = true),
ChannelPreset(126, "Wonder", listOf("Snow White and the Seven Dwarfs", "Pinocchio", "Dumbo", "Bambi", "Cinderella", "Alice in Wonderland", "Peter Pan", "Lady and the Tramp", "Sleeping Beauty", "One Hundred and One Dalmatians", "The Jungle Book", "The Aristocats", "Robin Hood",
"The Fox and the Hound", "The Little Mermaid", "Beauty and the Beast", "Aladdin", "The Lion King", "Pocahontas", "The Hunchback of Notre Dame", "Hercules", "Mulan", "Tarzan", "The Emperor's New Groove", "Atlantis: The Lost Empire", "Lilo & Stitch", "Treasure Planet", "Brother Bear", "The Princess and the Frog",
@@ -33,7 +33,7 @@ private val VortexEvening = listOf("Dragon Ball Z", "Dragon Ball", "Dragon Ball
private val NotCultCinema = setOf("Family", "Animation", "Documentary", "Music")
internal val SmartChannels = listOf(
ChannelProgramming(102, "After Dark", listOf("The Thing", "The Evil Dead", "Re-Animator", "Hellraiser", "Videodrome", "An American Werewolf in London",
ChannelProgramming(AFTER_DARK_NUMBER, "After Dark", listOf("The Thing", "The Evil Dead", "Re-Animator", "Hellraiser", "Videodrome", "An American Werewolf in London",
"Halloween", "Day of the Dead", "They Live", "Escape from New York", "The Fog", "Near Dark", "The Lost Boys", "Fright Night", "Society", "Tremors",
"Basket Case", "Phantasm", "Repo Man", "Donnie Darko", "The Wicker Man", "Suspiria", "Eraserhead", "Blue Velvet", "From Dusk Till Dawn", "Scream",
"Poltergeist", "The Howling", "Creepshow", "Prince of Darkness"), seriesCount = 40, filmRestMs = 7 * 24 * 60 * 60_000L,
@@ -46,7 +46,7 @@ internal val SmartChannels = listOf(
SuggestionRule("day", "Daytime", "Movie", listOf("Science Fiction", "Action"), 1975..2005, 6.5f, setOf("NC-17", "X"), 40, NotCultCinema + "Adventure", 70, 7.9f),
SuggestionRule("day", "Daytime", "Movie", listOf("Thriller", "Mystery"), 1975..2005, 6.5f, setOf("NC-17", "X"), 30, NotCultCinema + "Science Fiction", 70, 7.9f),
SuggestionRule("late", "Late night", "Movie", listOf("Horror"), 1970..2010, 6f, emptySet(), 90, NotCultCinema, 70))),
ChannelProgramming(103, "Vortex", VortexDaytime + VortexEvening, seriesCount = 40, finishTogether = true, exactTitles = true, englishAudio = false,
ChannelProgramming(VORTEX_NUMBER, "Vortex", VortexDaytime + VortexEvening, seriesCount = 40, finishTogether = true, exactTitles = true, englishAudio = false,
dayParts = listOf(DayPart(20, 22, group = "movie", days = setOf(java.time.DayOfWeek.FRIDAY, java.time.DayOfWeek.SATURDAY)),
DayPart(6, 18, VortexDaytime, group = "day"), DayPart(18, 6, VortexEvening, group = "night")),
suggestionRules = listOf(
@@ -23,6 +23,7 @@ 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.graphicsLayer
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.layout.ContentScale
@@ -53,19 +54,20 @@ 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)
internal val DriveInLateChannels = listOf(133, AFTER_DARK_NUMBER)
internal val DriveInBillChannels = listOf(136, 131, 132, 133, 134, 135, 126, 127, 114, 115, AFTER_DARK_NUMBER)
/** 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"))
private class NamedBill(val name: String, vararg films: String) { val films = films.toList() }
private val DoubleBills = listOf(NamedBill("Alien and Aliens", "Alien", "Aliens"), NamedBill("The Terminator Double Bill", "The Terminator", "Terminator 2: Judgment Day"), NamedBill("Predator and RoboCop", "Predator", "RoboCop"), NamedBill("The Evil Dead Double Bill", "Evil Dead II", "Army of Darkness"),
NamedBill("Bill & Ted's Double Bill", "Bill & Ted's Excellent Adventure", "Bill & Ted's Bogus Journey"), NamedBill("The Ghostbusters Double Bill", "Ghostbusters", "Ghostbusters II"), NamedBill("The Gremlins Double Bill", "Gremlins", "Gremlins 2: The New Batch"), NamedBill("The Karate Kid Double Bill", "The Karate Kid", "The Karate Kid Part II"),
NamedBill("The John Wick Double Bill", "John Wick", "John Wick: Chapter 2"), NamedBill("Kill Bill: The Whole Bloody Affair", "Kill Bill: Vol. 1", "Kill Bill: Vol. 2"), NamedBill("The Spider-Man Double Bill", "Spider-Man", "Spider-Man 2"), NamedBill("The Home Alone Double Bill", "Home Alone", "Home Alone 2: Lost in New York"),
NamedBill("The Lethal Weapon Double Bill", "Lethal Weapon", "Lethal Weapon 2"), NamedBill("The Trainspotting Double Bill", "Trainspotting", "T2 Trainspotting"), NamedBill("The 28 Days Double Bill", "28 Days Later", "28 Weeks Later"), NamedBill("The Incredibles Double Bill", "The Incredibles", "Incredibles 2"))
private val TripleBills = listOf(NamedBill("The Back to the Future Trilogy", "Back to the Future", "Back to the Future Part II", "Back to the Future Part III"), NamedBill("The Star Wars Trilogy", "Star Wars", "The Empire Strikes Back", "Return of the Jedi"),
NamedBill("The Indiana Jones Trilogy", "Raiders of the Lost Ark", "Indiana Jones and the Temple of Doom", "Indiana Jones and the Last Crusade"), NamedBill("The Cornetto Trilogy", "Shaun of the Dead", "Hot Fuzz", "The World's End"),
NamedBill("The Bourne Trilogy", "The Bourne Identity", "The Bourne Supremacy", "The Bourne Ultimatum"), NamedBill("The Die Hard Trilogy", "Die Hard", "Die Hard 2", "Die Hard: With a Vengeance"), NamedBill("The Dark Knight Trilogy", "Batman Begins", "The Dark Knight", "The Dark Knight Rises"),
NamedBill("The Toy Story Trilogy", "Toy Story", "Toy Story 2", "Toy Story 3"), NamedBill("The Mad Max Trilogy", "Mad Max", "Mad Max 2", "Mad Max: Fury Road"), NamedBill("The Hangover Trilogy", "The Hangover", "The Hangover Part II", "The Hangover Part III"),
NamedBill("The Lord of the Rings Trilogy", "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") }
@@ -74,7 +76,8 @@ internal class DriveInBill(val kind: DriveInKind, val entries: List<ScheduleEntr
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(" + ")
/** A bill's own name, such as The Toy Story Trilogy, or the film's title. */
val title get() = (DoubleBills + TripleBills).firstOrNull { it.films.map(::seriesKey) == films.map(::seriesKey) }?.name ?: films.joinToString(" + ")
}
/** An evening runs past midnight, so the small hours belong to the night before. */
@@ -109,8 +112,8 @@ internal fun planDriveIn(existing: ChannelPlan?, marquee: List<PoolSeries>, 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<List<String>>, startMs: Long, week: Long): List<PoolSeries>? = choices.indices.asSequence().map { choices[Math.floorMod(week + it, choices.size.toLong()).toInt()] }.mapNotNull { names ->
val films = names.map { byTitle[seriesKey(it)] ?: return@mapNotNull null }
fun billFor(choices: List<NamedBill>, startMs: Long, week: Long): List<PoolSeries>? = 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)
}
}
}
@@ -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)
@@ -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<Map<Int, ScheduledChannel>>(emptyMap()) }
var suggesting by remember { mutableStateOf<ChannelProgramming?>(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()
})
@@ -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 {
@@ -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)
}
}
@@ -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.
@@ -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()) {
@@ -21,7 +21,7 @@ internal fun SeriesReviewScreen(matches: List<SeriesMatches>, approved: Set<Stri
LaunchedEffect(episodes) { if (episodes != null) listState.scrollToItem(0) }
BackHandler(onBack = onBack)
Column(modifier.fillMaxWidth().background(Panel).padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("103 Vortex / Series check", color = Yellow, fontSize = 26.sp)
Text("$VORTEX_NUMBER Vortex / Series check", color = Yellow, fontSize = 26.sp)
Text("Choose up to three versions for this sample. Nothing is scheduled yet.", color = Muted, fontSize = 15.sp)
if (busy) Text("Preparing sample…", color = Yellow, fontSize = 15.sp)
message?.let { Text(it, color = Yellow, fontSize = 15.sp) }
@@ -60,7 +60,7 @@ internal fun SeriesReviewScreen(matches: List<SeriesMatches>, approved: Set<Stri
}
}
Text("${matches.size}/${ToonamiSearches.size} searches • ${approved.size}/3 series approved • Sample repeats; direct sources checked before scheduling.", color = Muted, fontSize = 14.sp)
if (episodes != null) OrbitButton("Create playable 103", enabled = !busy, onClick = onBuild)
if (episodes != null) OrbitButton("Create playable $VORTEX_NUMBER", enabled = !busy, onClick = onBuild)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (matches.size < ToonamiSearches.size) OrbitButton("Check next five series", enabled = !busy, onClick = onMore)
OrbitButton("Check sample episodes", enabled = !busy && approved.isNotEmpty(), onClick = onEpisodes)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 33 KiB

@@ -55,7 +55,7 @@ class AfterDarkMoviesTest {
val fake = Fake(); val active = EmbySampleSession(fake, "fixture-token", "fixture-user", EmbySample(emptyList(), 0))
active.checkAfterDarkMovies("Horror")
val channel = active.buildAfterDarkChannel(setOf("one", "two"))
assertEquals(102, channel.number); assertEquals("Orbit After Dark", channel.name)
assertEquals(130, channel.number); assertEquals("Orbit After Dark", channel.name)
assertEquals(5_400_123L, channel.lineup.first().durationMs)
assertEquals(3, fake.requests.size)
assertTrue(fake.requests.drop(1).all { !JSONObject(it.body!!).getBoolean("IsPlayback") })
@@ -69,33 +69,33 @@ class AfterDarkMoviesTest {
assertEquals(1, count)
}
@Test fun oldSavedSetupStillLoadsAnd102RoundTripKeepsTheExistingClock() {
val old = RememberedState(RememberedIdentity("fixture-user", "fixture-token", "fixture-device"), EmbySample(emptyList(), 0), emptyList(), emptySet(), null, null, null, 103)
val old = RememberedState(RememberedIdentity("fixture-user", "fixture-token", "fixture-device"), EmbySample(emptyList(), 0), emptyList(), emptySet(), null, null, null, 120)
val legacy = JSONObject(String(RememberedStateCodec.encode(old)))
for (key in listOf("afterDark", "darkMovies", "approvedDark", "selectedChannel")) legacy.remove(key)
val loaded = RememberedStateCodec.decode(legacy.toString().toByteArray())
assertEquals(103, loaded.selectedNumber); assertNull(loaded.afterDark); assertTrue(loaded.darkMovies.isEmpty())
assertEquals(120, loaded.selectedNumber); assertNull(loaded.afterDark); assertTrue(loaded.darkMovies.isEmpty())
val movies = listOf(candidate("one"), candidate("two"))
val channel = SampleChannel(movies.map { ScheduledMedia(it.item, "${it.item.id}-source", 5_400_123) }, "Orbit After Dark", 102)
val state = RememberedState(old.identity, old.sample, emptyList(), emptySet(), null, null, null, 102, channel, movies, setOf("one", "two"))
val channel = SampleChannel(movies.map { ScheduledMedia(it.item, "${it.item.id}-source", 5_400_123) }, "Orbit After Dark", 130)
val state = RememberedState(old.identity, old.sample, emptyList(), emptySet(), null, null, null, 130, channel, movies, setOf("one", "two"))
val restored = RememberedStateCodec.decode(RememberedStateCodec.encode(state))
assertEquals(channel.guide(1_793_491_234_567L), restored.afterDark!!.guide(1_793_491_234_567L))
assertEquals(102, restored.selectedNumber); assertEquals(state.approvedDark, restored.approvedDark)
assertEquals(102, state.withoutToken().afterDark!!.number)
assertEquals(101, JSONObject(String(RememberedStateCodec.encode(state))).getInt("selected"))
assertEquals(130, restored.selectedNumber); assertEquals(state.approvedDark, restored.approvedDark)
assertEquals(130, state.withoutToken().afterDark!!.number)
assertEquals(130, JSONObject(String(RememberedStateCodec.encode(state))).getInt("selected"))
}
@Test fun adding102Preserves101And103AndThreeWayGuideOrder() {
val movies = listOf(candidate("one"), candidate("two"))
val one = SampleChannel(movies.map { ScheduledMedia(it.item, "${it.item.id}-source", 5_400_000) })
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", 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 })
}
}
@@ -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"))
}
}
@@ -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)
}
@@ -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")
@@ -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"))
}
}
@@ -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
@@ -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 })
}
}
@@ -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") }
+13 -4
View File
@@ -31,9 +31,18 @@ for name, body in ICON.items():
SILVER = '<linearGradient id="s" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient>'
BG = '<linearGradient id="n" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#12356B"/><stop offset="1" stop-color="#071632"/></linearGradient>'
MARK = f'<path d="{CRESCENT}" fill="url(#s)"/><path d="M129 369C102 399 111 425 161 428C267 432 431 340 445 289C455 250 405 250 369 261" fill="none" stroke="#36BFFF" stroke-width="13"/><circle cx="430" cy="282" r="10" fill="#36BFFF"/>'
png = render(f'<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><defs>{SILVER}{BG}</defs><rect width="512" height="512" fill="url(#n)"/><g transform="translate(-34,-32) scale(1.1)">{MARK}</g></svg>', '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'<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 640 360"><defs>{SILVER}{BG}</defs><rect width="640" height="360" fill="url(#n)"/><g transform="translate(40,70) scale(.46)">{MARK}</g><text x="262" y="222" font-family="Barlow" font-weight="600" font-size="86" letter-spacing="22" fill="#F5F7FF">ORBIT</text></svg>', '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'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}"><defs>{BG}</defs>{background}</svg>', f'{name}_back.svg')).convert('RGBA')
front = Image.open(render(f'<svg xmlns="http://www.w3.org/2000/svg" width="2048" height="1024" viewBox="0 0 2048 1024"><defs>{SILVER}</defs>{art}</svg>', 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 = '<rect width="100%" height="100%" fill="url(#n)"/>'
centred('orbit_app_icon', 512, 512, NIGHT, f'<g transform="scale(2)">{MARK}</g>', .72).save(RES / 'orbit_app_icon.webp', quality=92)
centred('orbit_banner', 640, 360, NIGHT, f'<g transform="scale(1.5)">{MARK}</g><text x="810" y="520" font-family="Barlow" font-weight="600" font-size="210" letter-spacing="52" fill="#F5F7FF">ORBIT</text>', .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)
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><defs><linearGradient id="s" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient><linearGradient id="n" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#12356B"/><stop offset="1" stop-color="#071632"/></linearGradient></defs><rect width="512" height="512" fill="url(#n)"/><g transform="translate(-34,-32) scale(1.1)"><path d="M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z" fill="url(#s)"/><path d="M129 369C102 399 111 425 161 428C267 432 431 340 445 289C455 250 405 250 369 261" fill="none" stroke="#36BFFF" stroke-width="13"/><circle cx="430" cy="282" r="10" fill="#36BFFF"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="2048" height="1024" viewBox="0 0 2048 1024"><defs><linearGradient id="s" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient></defs><g transform="scale(2)"><path d="M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z" fill="url(#s)"/><path d="M129 369C102 399 111 425 161 428C267 432 431 340 445 289C455 250 405 250 369 261" fill="none" stroke="#36BFFF" stroke-width="13"/><circle cx="430" cy="282" r="10" fill="#36BFFF"/></g></svg>

Before

Width:  |  Height:  |  Size: 846 B

After

Width:  |  Height:  |  Size: 636 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 640 360"><defs><linearGradient id="s" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient><linearGradient id="n" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#12356B"/><stop offset="1" stop-color="#071632"/></linearGradient></defs><rect width="640" height="360" fill="url(#n)"/><g transform="translate(40,70) scale(.46)"><path d="M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z" fill="url(#s)"/><path d="M129 369C102 399 111 425 161 428C267 432 431 340 445 289C455 250 405 250 369 261" fill="none" stroke="#36BFFF" stroke-width="13"/><circle cx="430" cy="282" r="10" fill="#36BFFF"/></g><text x="262" y="222" font-family="Barlow" font-weight="600" font-size="86" letter-spacing="22" fill="#F5F7FF">ORBIT</text></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="2048" height="1024" viewBox="0 0 2048 1024"><defs><linearGradient id="s" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient></defs><g transform="scale(1.5)"><path d="M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z" fill="url(#s)"/><path d="M129 369C102 399 111 425 161 428C267 432 431 340 445 289C455 250 405 250 369 261" fill="none" stroke="#36BFFF" stroke-width="13"/><circle cx="430" cy="282" r="10" fill="#36BFFF"/></g><text x="810" y="520" font-family="Barlow" font-weight="600" font-size="210" letter-spacing="52" fill="#F5F7FF">ORBIT</text></svg>

Before

Width:  |  Height:  |  Size: 967 B

After

Width:  |  Height:  |  Size: 762 B

+1 -1
View File
@@ -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