diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 938bf95..4c51cd4 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 = 50 - versionName = "0.49.0-three-channels" + versionCode = 51 + versionName = "0.50.0-six-channels" } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt b/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt index cb30d78..d435d41 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt @@ -8,7 +8,10 @@ internal object ChannelBrands { 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), 111 to ChannelBranding("orbit-gold", "Orbit Gold", R.drawable.orbit_gold_epg, R.drawable.orbit_gold_background, R.drawable.orbit_gold_bug, ChannelCategory.Comedy), - 121 to ChannelBranding("heroes", "Orbit Heroes", R.drawable.heroes_epg, R.drawable.heroes_background, R.drawable.heroes_bug, ChannelCategory.Entertainment), + 121 to ChannelBranding("heroes", "Orbit Heroes", R.drawable.heroes_epg, R.drawable.heroes_background, R.drawable.heroes_bug, ChannelCategory.Cartoons), + 122 to ChannelBranding("toonz", "Orbit ToonZ", R.drawable.toonz_epg, R.drawable.toonz_background, R.drawable.toonz_bug, ChannelCategory.Cartoons), + 123 to ChannelBranding("rewind", "Orbit Rewind", R.drawable.rewind_epg, R.drawable.rewind_background, R.drawable.rewind_bug, ChannelCategory.Cartoons), + 124 to ChannelBranding("orbit-x", "Orbit X", R.drawable.orbit_x_epg, R.drawable.orbit_x_background, R.drawable.orbit_x_bug, ChannelCategory.Cartoons), 141 to ChannelBranding("evidence", "Orbit Evidence", R.drawable.evidence_epg, R.drawable.evidence_background, R.drawable.evidence_bug, ChannelCategory.Entertainment), 140 to ChannelBranding("orbit-terra", "Orbit Terra", R.drawable.orbit_terra_epg, R.drawable.orbit_terra_background, R.drawable.orbit_terra_bug, ChannelCategory.Documentaries), 152 to ChannelBranding("paradox", "Orbit Paradox", R.drawable.paradox_epg, R.drawable.paradox_background, R.drawable.paradox_bug, ChannelCategory.Entertainment)) diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelPresets.kt b/android/app/src/main/java/tv/orbit/classic/ChannelPresets.kt index 87931ea..09258ca 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelPresets.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelPresets.kt @@ -9,12 +9,24 @@ internal val ChannelPresets = listOf( 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"), smartOnly = true), + "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(121, "Heroes", listOf("Batman: The Animated Series", "Superman: The Animated Series", "Spider-Man: The Animated Series", "X-Men: The Animated Series", "Batman Beyond", "Spider-Man Unlimited", "Justice League", "Justice League Unlimited", "Teen Titans", "Static Shock", "The Spectacular Spider-Man", "Young Justice", - "Batman: The Brave and the Bold", "Iron Man: Armored Adventures", "The Avengers: Earth's Mightiest Heroes"), smartOnly = true), + "Batman: The Brave and the Bold", "Iron Man: Armored Adventures", "The Avengers: Earth's Mightiest Heroes", "The Batman", "Fantastic Four", "Silver Surfer", + "Spider-Man and His Amazing Friends", "Ben 10"), smartOnly = true), + ChannelPreset(122, "ToonZ", listOf("Looney Tunes", "The Looney Tunes Show", "Merrie Melodies", "The Bugs Bunny Show", "Tom and Jerry", "The Tom and Jerry Show", "The Flintstones", "The Jetsons", + "Popeye the Sailor", "Popeye", "Top Cat", "The Yogi Bear Show", "Wacky Races", "Scooby-Doo, Where Are You!", "The Pink Panther Show", "Woody Woodpecker", "Dastardly and Muttley in Their Flying Machines", + "The Huckleberry Hound Show", "Hong Kong Phooey", "Danger Mouse", "Count Duckula", "Bananaman"), smartOnly = true), + ChannelPreset(123, "Rewind", listOf("Dexter's Laboratory", "Johnny Bravo", "What's New, Scooby-Doo?", "Courage the Cowardly Dog", "Ed, Edd n Eddy", "Ed Edd n Eddy", "Recess", "The Powerpuff Girls", "Cow and Chicken", + "I Am Weasel", "The Grim Adventures of Billy & Mandy", "Codename: Kids Next Door", "Foster's Home for Imaginary Friends", "Hey Arnold!", "Rugrats", "Doug", "CatDog", "Rocko's Modern Life", + "Animaniacs", "Pinky and the Brain", "The Wild Thornberrys", "SpongeBob SquarePants"), smartOnly = true), + ChannelPreset(124, "Orbit X", listOf("Digimon: Digital Monsters", "Digimon Adventure", "Gargoyles", "The Transformers", "He-Man and the Masters of the Universe", "ThunderCats", "Biker Mice from Mars", + "Mighty Morphin Power Rangers", "G.I. Joe: A Real American Hero", "Teenage Mutant Ninja Turtles", "Street Sharks", "SWAT Kats: The Radical Squadron", "Captain Planet and the Planeteers", "M.A.S.K.", + "Exosquad", "The Real Ghostbusters", "Inspector Gadget", "She-Ra: Princess of Power", "Bucky O'Hare and the Toad Wars", "Visionaries: Knights of the Magical Light"), smartOnly = true), ChannelPreset(141, "Evidence", listOf("CSI: Crime Scene Investigation", "NCIS", "CSI: Miami", "CSI: NY", "Law & Order", "Law & Order: Special Victims Unit", "Criminal Minds", "Bones", - "Castle", "The Mentalist", "Monk", "Columbo", "Midsomer Murders", "Vera", "Luther", "Line of Duty", "Taggart", "Silent Witness", "Waking the Dead", "A Touch of Frost"), smartOnly = true)) + "Castle", "The Mentalist", "Monk", "Columbo", "Midsomer Murders", "Vera", "Luther", "Line of Duty", "Taggart", "Silent Witness", "Waking the Dead", "A Touch of Frost", "Dexter", "Sherlock", "Broadchurch", "Death in Paradise", "Lewis", "Inspector Morse", + "Dalziel and Pascoe", "Happy Valley", "The Wire", "Hawaii Five-0", "Blue Bloods"), smartOnly = true)) /** A fallback for a channel with no old loop: its first episodes, in turn. Used only outside its plan. */ internal fun loopFromPool(pool: List, programming: ChannelProgramming): SampleChannel? { diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt index 7ec36ea..f489645 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt @@ -57,7 +57,16 @@ internal val SmartChannels = listOf( "Hostile Planet", "Africa", "Wild China", "South Pacific", "Great Barrier Reef", "The Hunt", "The Life of Mammals", "The Life of Birds", "Nature's Great Events", "Green Planet", "Prehistoric Planet"), seriesCount = 20, finishTogether = true, exactTitles = true), ChannelProgramming(152, "Paradox", ChannelPresets.first { it.number == 152 }.searches)) + - ChannelPresets.filter { it.smartOnly }.map { ChannelProgramming(it.number, it.name, it.searches, seriesCount = 25, finishTogether = true, exactTitles = true) } + ChannelPresets.filter { it.smartOnly }.map { ChannelProgramming(it.number, it.name, it.searches, seriesCount = 40, finishTogether = true, exactTitles = true, + suggestionRules = SmartChannelRules[it.number].orEmpty()) } + +/** Optional bigger pools, only used once reviewed. No rule for Orbit Gold: nothing in the library's details can tell a British sitcom apart. */ +private val SmartChannelRules: Map> get() = mapOf( + 121 to listOf(SuggestionRule("all", "Superhero animation", "Series", listOf("Animation", "Action"), 1990..2020, 7f, setOf("TV-MA"), 30, setOf("Anime"), titleContains = null)), + 122 to listOf(SuggestionRule("all", "Classic cartoons", "Series", listOf("Animation", "Comedy"), 1940..1990, 6.5f, setOf("TV-MA"), 40)), + 123 to listOf(SuggestionRule("all", "After-school cartoons", "Series", listOf("Animation", "Comedy"), 1990..2010, 7f, setOf("TV-MA"), 40, setOf("Anime"))), + 124 to listOf(SuggestionRule("all", "Action cartoons", "Series", listOf("Animation", "Action"), 1980..2000, 6.5f, setOf("TV-MA"), 40, setOf("Anime"))), + 141 to listOf(SuggestionRule("all", "Crime drama", "Series", listOf("Crime", "Drama"), 1985..2022, 7f, emptySet(), 40, setOf("Documentary", "Animation")))) internal data class EpisodeMark(val season: Int, val episode: Int) : Comparable { override fun compareTo(other: EpisodeMark) = compareValuesBy(this, other, { it.season }, { it.episode }) @@ -110,31 +119,45 @@ internal fun dayPartPool(pool: List, parts: List, startMs: return pool.filter { !it.isFilm || pool.all(PoolSeries::isFilm) }.ifEmpty { pool } } +internal fun turnOrder(pool: List, last: String?): List { + val after = pool.indexOfFirst { it.id == last } + 1 + return (0 until pool.size).map { pool[(after + it) % pool.size] } +} + /** The next show after [last] in the rota that [eligible] allows. */ internal fun nextInTurn(pool: List, last: String?, eligible: (PoolSeries) -> Boolean): PoolSeries { - val after = pool.indexOfFirst { it.id == last } + 1 - return (0 until pool.size).map { pool[(after + it) % pool.size] }.first(eligible) + return turnOrder(pool, last).first(eligible) +} + +/** True if a show would overlap itself on another channel. */ +internal typealias ProgrammeClash = (show: PoolSeries, startMs: Long, endMs: Long) -> Boolean + +/** The same show may run on several channels, just not at the same moment. Compares by title, so two copies of a show still count. */ +internal fun clashesWith(plans: List): ProgrammeClash { + val airings = plans.flatMap { it.entries }.groupBy { seriesKey(it.media.item.title.substringBefore(" / ")) } + return { show, startMs, endMs -> airings[seriesKey(show.title)].orEmpty().any { it.startMs < endMs && startMs < it.endMs } } } /** * A film past its rest, chosen by a generator seeded from the start time so a plan can be explained and reproduced. * If the pool is too small for the rest period, the film that has waited longest. */ -internal fun nextFilm(pool: List, shown: Map, startMs: Long, restMs: Long): PoolSeries { - val rested = pool.filter { film -> shown[film.id]?.let { startMs - it >= restMs } ?: true } +internal fun nextFilm(pool: List, shown: Map, startMs: Long, restMs: Long, avoid: ProgrammeClash = { _, _, _ -> false }): PoolSeries { + val rested = pool.filter { film -> shown[film.id]?.let { startMs - it >= restMs } ?: true }.let { films -> + films.filter { !avoid(it, startMs, startMs + it.episodes.first().durationMs) }.ifEmpty { films } } return if (rested.isNotEmpty()) rested[java.util.Random(startMs).nextInt(rested.size)] else pool.minBy { shown[it.id] ?: Long.MIN_VALUE } } /** Films by their rest period, back to back from [fromMs] until [untilMs] is covered. */ internal fun extendFilmSchedule(pool: List, cursor: ScheduleCursor, fromMs: Long, untilMs: Long, restMs: Long, - dayParts: List = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault()): ChannelPlan { + dayParts: List = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }): ChannelPlan { val shown = cursor.filmShown.toMutableMap() val entries = mutableListOf() var last = cursor.lastSeriesId var start = fromMs while (start < untilMs) { - val film = nextFilm(dayPartPool(pool, dayParts, start, zone), shown, start, restMs) + val film = nextFilm(dayPartPool(pool, dayParts, start, zone), shown, start, restMs, avoid) val copy = film.episodes.first() val item = SampleItem(film.title, "Movie", copy.durationMs / 60_000, "", copy.id, copy.durationMs) entries += ScheduleEntry(film.id, copy.mark, ScheduledMedia(item, copy.sourceId, copy.durationMs), start) @@ -147,9 +170,9 @@ internal fun extendFilmSchedule(pool: List, cursor: ScheduleCursor, /** Shows take turns, one episode each, back to back from [fromMs] until [untilMs] is covered. */ internal fun extendSchedule(pool: List, cursor: ScheduleCursor, fromMs: Long, untilMs: Long, filmRestMs: Long? = null, finishTogether: Boolean = false, - dayParts: List = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault()): ChannelPlan { + dayParts: List = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }): ChannelPlan { require(pool.isNotEmpty() && pool.all { it.episodes.isNotEmpty() }) - if (filmRestMs != null) return extendFilmSchedule(pool, cursor, fromMs, untilMs, filmRestMs, dayParts, zone) + if (filmRestMs != null) return extendFilmSchedule(pool, cursor, fromMs, untilMs, filmRestMs, dayParts, zone, avoid) val marks = cursor.marks.toMutableMap() val entries = mutableListOf() var last = cursor.lastSeriesId @@ -158,7 +181,9 @@ internal fun extendSchedule(pool: List, cursor: ScheduleCursor, from val available = dayPartPool(pool, dayParts, start, zone) // Shows only wait for others in the same block, or a daytime show could stand idle for an evening one. if (finishTogether && available.all { marks[it.id] == it.episodes.last().mark }) available.forEach { marks.remove(it.id) } - val series = nextInTurn(pool, last) { it in available && (!finishTogether || marks[it.id] != it.episodes.last().mark) } + val allowed = { show: PoolSeries -> show in available && (!finishTogether || marks[show.id] != show.episodes.last().mark) } + // Skips a show already on elsewhere, unless that would leave nothing to show. + val series = turnOrder(pool, last).firstOrNull { allowed(it) && !avoid(it, start, start + nextEpisode(it, marks[it.id]).durationMs) } ?: nextInTurn(pool, last, allowed) val episode = nextEpisode(series, marks[series.id]) val item = if (series.isFilm) SampleItem(series.title, "Movie", episode.durationMs / 60_000, "", episode.id, episode.durationMs) else scheduledItem(series.title, episode) val media = ScheduledMedia(item, episode.sourceId, episode.durationMs) @@ -191,12 +216,12 @@ internal fun airedCursor(base: ScheduleCursor, entries: List, now * With no usable plan it starts from now at the aired position. */ internal fun topUpPlan(existing: ChannelPlan?, aired: ScheduleCursor, pool: List?, nowMs: Long, filmRestMs: Long? = null, finishTogether: Boolean = false, - dayParts: List = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault()): ChannelPlan? { + dayParts: List = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }): ChannelPlan? { val kept = existing?.let { plan -> plan.copy(entries = plan.entries.dropWhile { it.endMs < nowMs - SCHEDULE_KEEP_PAST_MS }) } if (kept != null && kept.endMs >= nowMs + SCHEDULE_TOP_UP_MS) return kept if (pool.isNullOrEmpty()) return kept val from = if (kept != null && kept.endMs > nowMs) kept.endMs else nowMs - val added = extendSchedule(pool, existing?.tail ?: aired, from, nowMs + SCHEDULE_AHEAD_MS, filmRestMs, finishTogether, dayParts, zone) + val added = extendSchedule(pool, existing?.tail ?: aired, from, nowMs + SCHEDULE_AHEAD_MS, filmRestMs, finishTogether, dayParts, zone, avoid) return ChannelPlan(kept?.entries.orEmpty() + added.entries, added.tail) } diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt index 1346e79..ccddacf 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt @@ -67,7 +67,10 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl val existing = store.loadPlan(userId, number) val progress = store.loadProgress(userId, number) val base = progress?.aired ?: ScheduleCursor() - val plan = topUpPlan(existing, base, pool?.series, now, programming.filmRestMs, programming.finishTogether, programming.dayParts) ?: throw EmbyCheckException("${programming.name} has no episodes to schedule.") + // Other channels' plans are only read if this one is actually about to grow. + val others by lazy { clashesWith(SmartChannels.filter { it.number != number }.mapNotNull { store.loadPlan(userId, it.number) }) } + val plan = topUpPlan(existing, base, pool?.series, now, programming.filmRestMs, programming.finishTogether, programming.dayParts, + avoid = { show, startMs, endMs -> others(show, startMs, endMs) }) ?: throw EmbyCheckException("${programming.name} has no episodes to schedule.") if (plan !== existing) store.savePlan(userId, number, plan) store.saveProgress(userId, number, ScheduleStore.SavedProgress(progress?.enabled ?: true, airedCursor(base, plan.entries, now))) session.rememberScheduledItems(plan.entries.map { it.media.item }) 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 e09e716..349471f 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -41,7 +41,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.49.0\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.50.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/HomeScreen.kt b/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt index 9ad2298..f599b8b 100644 --- a/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt @@ -27,6 +27,7 @@ internal enum class ChannelCategory(val label: String, val icon: Int) { Entertainment("Entertainment", R.drawable.orbit_icon_entertainment), Movies("Movies", R.drawable.orbit_icon_movies), Comedy("Comedy", R.drawable.orbit_icon_comedy), + Cartoons("Cartoons", R.drawable.orbit_icon_kids), Kids("Kids", R.drawable.orbit_icon_kids), Documentaries("Documentaries", R.drawable.orbit_icon_documentaries), Sport("Sport", R.drawable.orbit_icon_sport) 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 7a33173..38d9bb4 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.49.0") + next.setRequestProperty("User-Agent", "OrbitTV/0.50.0") next.setRequestProperty("Accept-Encoding", "identity") session.mediaHeaders(address).forEach { (key, value) -> next.setRequestProperty(key, value) } if (dataSpec.position != 0L || dataSpec.length != C.LENGTH_UNSET.toLong()) { diff --git a/android/app/src/main/res/drawable-nodpi/orbit_x_background.webp b/android/app/src/main/res/drawable-nodpi/orbit_x_background.webp new file mode 100644 index 0000000..2ba12c0 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_x_background.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_x_bug.webp b/android/app/src/main/res/drawable-nodpi/orbit_x_bug.webp new file mode 100644 index 0000000..f480321 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_x_bug.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_x_epg.webp b/android/app/src/main/res/drawable-nodpi/orbit_x_epg.webp new file mode 100644 index 0000000..c0ae5f5 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_x_epg.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/rewind_background.webp b/android/app/src/main/res/drawable-nodpi/rewind_background.webp new file mode 100644 index 0000000..47a950e Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/rewind_background.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/rewind_bug.webp b/android/app/src/main/res/drawable-nodpi/rewind_bug.webp new file mode 100644 index 0000000..dc6b2b7 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/rewind_bug.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/rewind_epg.webp b/android/app/src/main/res/drawable-nodpi/rewind_epg.webp new file mode 100644 index 0000000..5a0aefa Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/rewind_epg.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/toonz_background.webp b/android/app/src/main/res/drawable-nodpi/toonz_background.webp new file mode 100644 index 0000000..18ebf05 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/toonz_background.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/toonz_bug.webp b/android/app/src/main/res/drawable-nodpi/toonz_bug.webp new file mode 100644 index 0000000..1bc9464 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/toonz_bug.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/toonz_epg.webp b/android/app/src/main/res/drawable-nodpi/toonz_epg.webp new file mode 100644 index 0000000..2d740fc Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/toonz_epg.webp differ 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 bba033b..088a92d 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, 121, 141).forEach { number -> + listOf(111, 121, 122, 123, 124, 141).forEach { number -> assertTrue(SmartChannels.any { it.number == number && it.exactTitles && it.finishTogether }) assertNotNull(ChannelBrands.forNumber(number)) assertTrue(ChannelPresets.first { it.number == number }.smartOnly) 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 af51231..d74750d 100644 --- a/android/app/src/test/java/tv/orbit/classic/ChannelScheduleTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/ChannelScheduleTest.kt @@ -405,4 +405,49 @@ class ChannelScheduleTest { dayParts = listOf(DayPart(6, 18, listOf("Show s")), DayPart(18, 6, listOf("Show o"))), zone = london).entries assertEquals(List(6) { "s" }, entries.filter { localHour(it.startMs) in 6 until 18 }.map { it.seriesId }) } + + private fun programmeOverlaps(a: List, b: List) = a.sumOf { x -> b.count { y -> + x.media.item.title.substringBefore(" / ") == y.media.item.title.substringBefore(" / ") && x.startMs < y.endMs && y.startMs < x.endMs } } + + @Test fun aSharedShowNeverAirsOnTwoChannelsAtOnce() { + val shared = listOf(show("batman", 9, 40), show("spider", 9, 40), show("xmen", 9, 40)) + val first = topUpPlan(null, ScheduleCursor(), shared, now)!! + val second = topUpPlan(null, ScheduleCursor(), shared, now, avoid = clashesWith(listOf(first)))!! + assertEquals(0, programmeOverlaps(first.entries, second.entries)) + assertTrue(second.entries.map { it.seriesId }.toSet().size == 3) + } + + @Test fun withoutTheRuleTheSamePoolWouldClash() { + val shared = listOf(show("batman", 9, 40), show("spider", 9, 40)) + val first = topUpPlan(null, ScheduleCursor(), shared, now)!! + assertTrue(programmeOverlaps(first.entries, topUpPlan(null, ScheduleCursor(), shared, now)!!.entries) > 0) + } + + @Test fun aChannelWithOnlyAClashingShowStillStaysOnAir() { + val only = listOf(show("batman", 9, 40)) + val other = topUpPlan(null, ScheduleCursor(), only, now)!! + val plan = topUpPlan(null, ScheduleCursor(), only, now, avoid = clashesWith(listOf(other)))!! + assertEquals(other.entries.size, plan.entries.size) + } + + @Test fun twoCopiesOfAShowCountAsTheSameShow() { + val copyA = PoolSeries("copy-a", "Dragon Ball Z", listOf(PoolEpisode("a1", "s", EpisodeMark(1, 1), "E", 30 * minute))) + val copyB = PoolSeries("copy-b", "The Dragon Ball Z", listOf(PoolEpisode("b1", "s", EpisodeMark(1, 1), "E", 30 * minute))) + val plan = extendSchedule(listOf(copyA), ScheduleCursor(), now, now + 30 * minute) + assertTrue(clashesWith(listOf(plan))(copyB, now + 10 * minute, now + 20 * minute)) + assertFalse(clashesWith(listOf(plan))(copyB, now + 30 * minute, now + 60 * minute)) + } + + @Test fun filmsAvoidClashesToo() { + val other = extendSchedule(films, ScheduleCursor(), now, now + 7 * day, rest) + val mine = extendSchedule(films, ScheduleCursor(), now, now + 7 * day, rest, avoid = clashesWith(listOf(other))) + assertEquals(0, programmeOverlaps(other.entries, mine.entries)) + } + + @Test fun aFreshPlanDoesNotReadOtherChannels() { + var asked = false + val first = topUpPlan(null, ScheduleCursor(), pool, now)!! + topUpPlan(first, ScheduleCursor(), pool, now + minute, avoid = { _, _, _ -> asked = true; false }) + assertFalse(asked) + } } diff --git a/docs/roadmap.md b/docs/roadmap.md index 2f0d2d6..9d373a9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -71,7 +71,7 @@ Audit and phased plan are in review, from `docs/briefs/indexing-programming-engi ## Smart channels status -Phase A of [the Smart Channels Foundation brief](briefs/smart-channels-foundation-2026-11-01.md) passed Paul's review on 2 November 2026, with Paradox on the box (VERIFICATION-0.39.0.md). Phase B (Terra and After Dark, VERIFICATION-0.40.0.md) and Phase C (Vortex, plus fewer repeats on Terra, VERIFICATION-0.41.0.md) are on the box as 0.41, tested and awaiting Paul's review. Day parts are on the box on Vortex and After Dark (VERIFICATION-0.42.0.md). Phase D (the remaining channels) needs separate approval. Reviewed suggestions are built and in use on After Dark and Vortex (VERIFICATION-0.47.0.md), the first piece of the programming director. Vortex Movie Night, the first weekday event, is on the box. Phase D has started with Orbit Gold (111), Heroes (121) and Evidence (141), built as 0.49. Their numbers are placeholders until Paul maps the lineup. Animated idents are design files only and aren't in the app yet. +Phase A of [the Smart Channels Foundation brief](briefs/smart-channels-foundation-2026-11-01.md) passed Paul's review on 2 November 2026, with Paradox on the box (VERIFICATION-0.39.0.md). Phase B (Terra and After Dark, VERIFICATION-0.40.0.md) and Phase C (Vortex, plus fewer repeats on Terra, VERIFICATION-0.41.0.md) are on the box as 0.41, tested and awaiting Paul's review. Day parts are on the box on Vortex and After Dark (VERIFICATION-0.42.0.md). Phase D (the remaining channels) needs separate approval. Reviewed suggestions are built and in use on After Dark and Vortex (VERIFICATION-0.47.0.md), the first piece of the programming director. Vortex Movie Night, the first weekday event, is on the box. Phase D has started with Orbit Gold (111), Heroes (121) and Evidence (141), on the box as 0.49, then ToonZ (122), Rewind (123) and Orbit X (124), built as 0.50. Numbers are placeholders until Paul maps the lineup. A show may run on several channels but never at the same moment. Animated idents are design files only and aren't in the app yet. ## Programming director