Add film rotation, Terra and After Dark, guide day jump and clearer switch (0.40.0, Phase B in testing)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m54s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m25s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-02 06:18:54 +00:00
co-authored by Claude Opus 5.5
parent 10f0c90136
commit 6ed45810e6
13 changed files with 219 additions and 37 deletions
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 40
versionName = "0.39.0-smart-paradox"
versionCode = 41
versionName = "0.40.0-smart-terra-after-dark"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -10,7 +10,7 @@ internal val ChannelPresets = listOf(
/** What a build used, so the viewer can see what the library did and didn't have. */
internal class PresetBuild(val channel: SampleChannel, val shows: List<String>, val missing: List<String>)
private fun seriesKey(title: String) = title.lowercase(java.util.Locale.ROOT).replace(Regex("[^a-z0-9 ]"), " ").trim().removePrefix("the ").replace(Regex(" +"), " ")
internal fun seriesKey(title: String) = title.lowercase(java.util.Locale.ROOT).replace(Regex("[^a-z0-9 ]"), " ").trim().removePrefix("the ").replace(Regex(" +"), " ")
/** The same title, or else the title with something after it, such as Blue Planet II. Oldest first, so an original beats its revival. */
internal fun bestSeriesMatch(requested: String, candidates: List<SeriesCandidate>): SeriesCandidate? {
@@ -1,9 +1,18 @@
package tv.orbit.classic
/** What makes a channel itself. The engine below knows nothing about any particular channel. */
internal data class ChannelProgramming(val number: Int, val name: String, val searches: List<String>, val seriesCount: Int = 3)
internal data class ChannelProgramming(val number: Int, val name: String, val searches: List<String>, val seriesCount: Int = 3,
/** Set for a film channel: how long before a film may air again, as long as the pool allows it. */
val filmRestMs: Long? = null)
internal val SmartChannels = listOf(ChannelProgramming(152, "Paradox", ChannelPresets.first { it.number == 152 }.searches))
internal val SmartChannels = listOf(
ChannelProgramming(102, "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 = 48 * 60 * 60_000L),
ChannelProgramming(140, "Terra", listOf("Blue Planet", "Planet Earth", "Frozen Planet", "Life on Earth", "Our Planet", "Dynasties",
"Seven Worlds One Planet", "A Perfect Planet", "Hostile Planet", "The Living Planet"), seriesCount = 8),
ChannelProgramming(152, "Paradox", ChannelPresets.first { it.number == 152 }.searches))
internal data class EpisodeMark(val season: Int, val episode: Int) : Comparable<EpisodeMark> {
override fun compareTo(other: EpisodeMark) = compareValuesBy(this, other, { it.season }, { it.episode })
@@ -12,7 +21,9 @@ internal data class PoolEpisode(val id: String, val sourceId: String, val mark:
internal data class PoolSeries(val id: String, val title: String, val episodes: List<PoolEpisode>)
/** Where a channel has got to: the last episode of each show, and whose turn was last. */
internal data class ScheduleCursor(val lastSeriesId: String? = null, val marks: Map<String, EpisodeMark> = emptyMap())
internal data class ScheduleCursor(val lastSeriesId: String? = null, val marks: Map<String, EpisodeMark> = emptyMap(),
/** When each film last started, for its rest period. */
val filmShown: Map<String, Long> = emptyMap())
internal data class ScheduleEntry(val seriesId: String, val mark: EpisodeMark, val media: ScheduledMedia, val startMs: Long) {
val endMs get() = startMs + media.durationMs
@@ -31,9 +42,38 @@ internal const val SCHEDULE_KEEP_PAST_MS = 24 * 60 * 60_000L
internal fun nextEpisode(series: PoolSeries, last: EpisodeMark?): PoolEpisode =
last?.let { mark -> series.episodes.firstOrNull { it.mark > mark } } ?: series.episodes.first()
/**
* 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<PoolSeries>, shown: Map<String, Long>, startMs: Long, restMs: Long): PoolSeries {
val rested = pool.filter { film -> shown[film.id]?.let { startMs - it >= restMs } ?: true }
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<PoolSeries>, cursor: ScheduleCursor, fromMs: Long, untilMs: Long, restMs: Long): ChannelPlan {
val shown = cursor.filmShown.toMutableMap()
val entries = mutableListOf<ScheduleEntry>()
var last = cursor.lastSeriesId
var start = fromMs
while (start < untilMs) {
val film = nextFilm(pool, shown, start, restMs)
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)
shown[film.id] = start
last = film.id
start += copy.durationMs
}
return ChannelPlan(entries, ScheduleCursor(last, cursor.marks, shown))
}
/** Shows take turns, one episode each, back to back from [fromMs] until [untilMs] is covered. */
internal fun extendSchedule(pool: List<PoolSeries>, cursor: ScheduleCursor, fromMs: Long, untilMs: Long): ChannelPlan {
internal fun extendSchedule(pool: List<PoolSeries>, cursor: ScheduleCursor, fromMs: Long, untilMs: Long, filmRestMs: Long? = null): ChannelPlan {
require(pool.isNotEmpty() && pool.all { it.episodes.isNotEmpty() })
if (filmRestMs != null) return extendFilmSchedule(pool, cursor, fromMs, untilMs, filmRestMs)
val marks = cursor.marks.toMutableMap()
val entries = mutableListOf<ScheduleEntry>()
var last = cursor.lastSeriesId
@@ -58,20 +98,24 @@ internal fun scheduledItem(seriesTitle: String, episode: PoolEpisode) = SampleIt
internal fun airedCursor(base: ScheduleCursor, entries: List<ScheduleEntry>, nowMs: Long): ScheduleCursor {
val marks = base.marks.toMutableMap()
var last = base.lastSeriesId
entries.takeWhile { it.startMs <= nowMs }.forEach { marks[it.seriesId] = it.mark; last = it.seriesId }
return ScheduleCursor(last, marks)
val shown = base.filmShown.toMutableMap()
entries.takeWhile { it.startMs <= nowMs }.forEach {
marks[it.seriesId] = it.mark; last = it.seriesId
if (it.media.item.type == "Movie") shown[it.seriesId] = it.startMs
}
return ScheduleCursor(last, marks, shown)
}
/**
* Only ever appends. Programmes already committed stay as they are, apart from dropping old ones.
* With no usable plan it starts from now at the aired position.
*/
internal fun topUpPlan(existing: ChannelPlan?, aired: ScheduleCursor, pool: List<PoolSeries>?, nowMs: Long): ChannelPlan? {
internal fun topUpPlan(existing: ChannelPlan?, aired: ScheduleCursor, pool: List<PoolSeries>?, nowMs: Long, filmRestMs: Long? = null): 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)
val added = extendSchedule(pool, existing?.tail ?: aired, from, nowMs + SCHEDULE_AHEAD_MS, filmRestMs)
return ChannelPlan(kept?.entries.orEmpty() + added.entries, added.tail)
}
@@ -30,7 +30,7 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
* Brings a channel's plan up to seven days ahead, reading the provider only if the cached pool is missing or old.
* A failed refresh keeps what's there, so a provider outage can't disturb what's committed.
*/
fun topUp(session: EmbySampleSession, userId: String, programming: ChannelProgramming, checkActive: () -> Unit = {}): SchedulerReport {
fun topUp(session: EmbySampleSession, userId: String, programming: ChannelProgramming, loop: SampleChannel, checkActive: () -> Unit = {}): SchedulerReport {
val number = programming.number
val now = clock()
val cached = store.loadPool(userId, number)
@@ -40,7 +40,7 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
if (cached == null || now - cached.fetchedAtMs >= POOL_REFRESH_MS) {
val started = android.os.SystemClock.elapsedRealtime()
try {
val read = session.readChannelPool(programming, checkActive)
val read = session.readChannelPool(programming, loop, checkActive)
requests = read.requests
fetchMs = android.os.SystemClock.elapsedRealtime() - started
if (read.series.isNotEmpty()) {
@@ -53,7 +53,7 @@ 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) ?: throw EmbyCheckException("${programming.name} has no episodes to schedule.")
val plan = topUpPlan(existing, base, pool?.series, now, programming.filmRestMs) ?: 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 })
@@ -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.39.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.40.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -94,6 +94,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
val scheduleStore = remember { ScheduleStore(java.io.File(appContext.noBackupFilesDir, "schedule")) }
val scheduler = remember { ChannelScheduler(scheduleStore) }
var scheduledChannels by remember { mutableStateOf<Map<Int, ScheduledChannel>>(emptyMap()) }
var lastSmartPress by remember { mutableLongStateOf(0L) }
fun onAir(loop: SampleChannel) = scheduledChannels[loop.number]?.takeIf { it.loop === loop } ?: loop
var channelClockOffset by remember { mutableLongStateOf(0) }
var busyLabel by remember { mutableStateOf("Connecting…") }
@@ -115,7 +116,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, toonamiChannel, *extraChannels.map(::onAir).toTypedArray())
val available = availableSampleChannels(channel, afterDarkChannel?.let(::onAir), toonamiChannel, *extraChannels.map(::onAir).toTypedArray())
selectedNumber = available.firstOrNull { it.number == saved.selectedNumber }?.number ?: available.firstOrNull()?.number ?: 101
onChannelAvailable(available.isNotEmpty())
}
@@ -173,7 +174,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
}
// Read before the channels appear, so the guide never shows the loop for a moment first.
scheduledChannels = withContext(Dispatchers.IO) {
saved.extra.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.afterDark)).mapNotNull { loop -> scheduler.load(saved.identity.userId, loop)?.also { resumed.rememberScheduledItems(it.plan.entries.map { entry -> entry.media.item }) } }.associateBy { it.number }
}
session = resumed; sample = resumed.sample; applyChoices(saved)
saveTicket = savedStore.ticket()
@@ -212,16 +213,17 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
}
}
LaunchedEffect(Unit) { while (true) { now = System.currentTimeMillis(); delay(1000) } }
fun smartLoop(programming: ChannelProgramming) = (extraChannels + listOfNotNull(afterDarkChannel)).firstOrNull { it.number == programming.number }
// Keeps smart channels topped up in the background. A failure changes nothing on air.
LaunchedEffect(session, extraChannels) {
LaunchedEffect(session, extraChannels, afterDarkChannel) {
val active = session ?: return@LaunchedEffect
while (true) {
for (programming in SmartChannels) {
val loop = extraChannels.firstOrNull { it.number == programming.number } ?: continue
val loop = smartLoop(programming) ?: continue
try {
withContext(Dispatchers.IO) {
if (scheduler.enabled(active.accountId, programming.number)) {
scheduler.topUp(active, active.accountId, programming)
scheduler.topUp(active, active.accountId, programming, loop)
val fresh = scheduleStore.loadPlan(active.accountId, programming.number)
val onAirNow = scheduledChannels[programming.number]?.takeIf { it.loop === loop }
if (fresh != null && onAirNow != null) onAirNow.plan = fresh
@@ -251,7 +253,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
val ready = !busy && !restoring && now >= nextAttemptAt && username.isNotBlank() && password.text.isNotEmpty()
playback?.let { plan -> session?.let { active -> EmbyPlaybackDialog(active, plan, onClose = { playback = null }) } }
val liveSession = session
val availableChannels = availableSampleChannels(channel, afterDarkChannel, toonamiChannel, *extraChannels.map(::onAir).toTypedArray())
val availableChannels = availableSampleChannels(channel, afterDarkChannel?.let(::onAir), toonamiChannel, *extraChannels.map(::onAir).toTypedArray())
val liveChannel = availableChannels.firstOrNull { it.number == selectedNumber }
if ((mainGuide || mainHome) && liveSession != null && liveChannel != null) {
if (watchingChannel) ScheduledChannelPlayer(liveSession, liveChannel, channelClockOffset,
@@ -422,8 +424,10 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
}
fun setSmartSchedule(programming: ChannelProgramming, turnOn: Boolean) {
val active = session ?: return
val loop = extraChannels.firstOrNull { it.number == programming.number } ?: return
if (busy) return
val loop = smartLoop(programming) ?: return
// A second press straight after the first is taken as a slip.
if (busy || now - lastSmartPress < 5_000) return
lastSmartPress = now
busy = true; busyLabel = "Reading ${programming.name}'s whole series and planning seven days…"; message = null
job = scope.launch {
try {
@@ -431,11 +435,11 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
val report = withContext(Dispatchers.IO) {
val context = coroutineContext
scheduler.setEnabled(active.accountId, programming.number, true)
scheduler.topUp(active, active.accountId, programming) { context.ensureActive() }.also {
scheduler.topUp(active, active.accountId, programming, loop) { context.ensureActive() }.also {
scheduler.load(active.accountId, loop)?.let { on -> scheduledChannels = scheduledChannels + (programming.number to on) }
}
}
message = "${programming.name} is scheduled: ${report.programmes} programmes from ${report.episodes} episodes of ${report.shows.joinToString()}. " +
message = "${programming.name} is scheduled: ${report.programmes} programmes from ${report.episodes} titles: ${report.shows.take(10).joinToString()}${if (report.shows.size > 10) " and ${report.shows.size - 10} more" else ""}. " +
"${report.requests} requests, ${report.fetchMs / 1000} s to read, ${report.planMs} ms to plan, ${report.bytes / 1024} KB stored."
} else {
withContext(Dispatchers.IO) { scheduler.setEnabled(active.accountId, programming.number, false) }
@@ -636,8 +640,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
}
SmartChannels.forEach { programming ->
val on = scheduledChannels.containsKey(programming.number)
OrbitButton("${programming.number} ${programming.name} — smart schedule ${if (on) "on (press for loop)" else "off (press to start)"}", Modifier.fillMaxWidth(),
enabled = !busy && session != null && extraChannels.any { it.number == programming.number }, onClick = { setSmartSchedule(programming, !on) })
OrbitButton("${programming.number} ${programming.name} — smart schedule is ${if (on) "ON. Press to go back to its loop" else "OFF. Press to turn on"}", Modifier.fillMaxWidth(),
enabled = !busy && session != null && smartLoop(programming) != null, onClick = { setSmartSchedule(programming, !on) })
}
OrbitButton("Check metadata coverage", Modifier.fillMaxWidth(),
enabled = !busy && session != null, onClick = { message = null; reviewingCoverage = true })
@@ -186,9 +186,21 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
internal class PoolRead(val series: List<PoolSeries>, val requests: Int)
/** The first shows in the channel's list that have episodes to schedule. */
fun readChannelPool(programming: ChannelProgramming, checkActive: () -> Unit = {}): PoolRead {
fun readChannelPool(programming: ChannelProgramming, loop: SampleChannel, checkActive: () -> Unit = {}): PoolRead {
var requests = 0
val pool = mutableListOf<PoolSeries>()
if (programming.filmRestMs != null) {
// Films you've already chosen for this channel stay in it.
loop.lineup.filter { it.item.type == "Movie" }.forEach { pool += PoolSeries(it.item.id, it.item.title, listOf(PoolEpisode(it.item.id, it.sourceId, EpisodeMark(1, 1), it.item.title, it.durationMs))) }
for (title in programming.searches) {
if (pool.size >= programming.seriesCount) break
checkActive()
requests++
val film = findFilm(transport, userId, ::currentToken, title) ?: continue
if (pool.none { it.id == film.id }) pool += film
}
return PoolRead(pool, requests)
}
for (search in programming.searches) {
if (pool.size == programming.seriesCount) break
checkActive()
@@ -87,3 +87,28 @@ private fun poolEpisode(item: org.json.JSONObject): PoolEpisode? {
val duration = (ticks / 10_000).takeIf { it in 1_000..SampleChannel.MAX_DURATION_MS } ?: return null
return PoolEpisode(id, source.getString("Id"), EpisodeMark(season, number), EmbyClient.clean(item.optString("Name"), 160).ifBlank { "Untitled episode" }, duration)
}
/** One film by its exact title, oldest first, with a playable source. Near matches don't count, so a sequel never slips in. */
internal fun findFilm(transport: EmbyTransport, userId: String, token: () -> String, title: String): PoolSeries? {
require(EmbyClient.validId(userId))
val term = java.net.URLEncoder.encode(title, "UTF-8")
val reply = transport.execute(EmbyRequest("GET", "/Items?UserId=$userId&Recursive=true&IncludeItemTypes=Movie&SearchTerm=$term&StartIndex=0&Limit=10&SortBy=SortName&SortOrder=Ascending&Fields=MediaSources&EnableImages=false&EnableUserData=false",
token = token(), readTimeoutMs = WHOLE_SERIES_TIMEOUT_MS))
EmbyClient.expectSuccess(reply.status, false)
val rows = EmbyClient.parse(reply.body).optJSONArray("Items") ?: return null
val key = seriesKey(title)
return (0 until minOf(rows.length(), 10)).mapNotNull { rows.optJSONObject(it) }
.filter { it.optString("Type") == "Movie" && seriesKey(EmbyClient.clean(it.optString("Name"), 160)) == key }
.sortedBy { it.optInt("ProductionYear", Int.MAX_VALUE).takeIf { year -> year > 0 } ?: Int.MAX_VALUE }
.firstNotNullOfOrNull { row ->
val id = row.optString("Id")
if (!EmbyClient.validId(id)) return@firstNotNullOfOrNull null
val sources = row.optJSONArray("MediaSources") ?: return@firstNotNullOfOrNull null
val source = (0 until minOf(sources.length(), 20)).mapNotNull { sources.optJSONObject(it) }
.firstOrNull { supportedDirectContainer(it.optString("Container")) != null && EmbyClient.validId(it.optString("Id")) } ?: return@firstNotNullOfOrNull null
val ticks = source.optLong("RunTimeTicks", 0).takeIf { it > 0 } ?: row.optLong("RunTimeTicks", 0)
val duration = (ticks / 10_000).takeIf { it in 1_000..SampleChannel.MAX_DURATION_MS } ?: return@firstNotNullOfOrNull null
val name = EmbyClient.clean(row.optString("Name"), 160)
PoolSeries(id, name, listOf(PoolEpisode(id, source.getString("Id"), EpisodeMark(1, 1), name, duration)))
}
}
@@ -57,6 +57,10 @@ internal fun guideDayPrefix(timeMs: Long, nowMs: Long): String {
return if (day.format(java.util.Date(timeMs)) == day.format(java.util.Date(nowMs))) "" else java.text.SimpleDateFormat("EEE d MMM ", java.util.Locale.UK).format(java.util.Date(timeMs))
}
/** Holding left or right jumps a day at a time, staying between now and a week ahead. */
internal fun guideDayJump(cursorMs: Long, nowMs: Long, forward: Boolean): Long =
(maxOf(cursorMs, nowMs) + if (forward) 24 * 60 * 60_000L else -24 * 60 * 60_000L).coerceIn(nowMs, nowMs + GUIDE_MAX_SLOTS_AHEAD * GUIDE_SLOT_MS - 1)
/** The first row to draw, moving only as far as it takes to keep the chosen row on screen. */
internal fun guideFirstRow(row: Int, first: Int, visible: Int, count: Int): Int {
val last = (count - visible).coerceAtLeast(0)
@@ -139,10 +143,15 @@ internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modif
when (native.keyCode) {
KeyEvent.KEYCODE_DPAD_UP -> { if (row > 0) row-- else toolsFocus.requestFocus(); true }
KeyEvent.KEYCODE_DPAD_DOWN -> { if (row < channels.lastIndex) row++; true }
KeyEvent.KEYCODE_DPAD_RIGHT -> {
if (slotsAhead < GUIDE_MAX_SLOTS_AHEAD) keepCursorInWindow(guideStep(channel, cursor, now, true)); true
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_LEFT -> {
val forward = native.keyCode == KeyEvent.KEYCODE_DPAD_RIGHT
when {
native.repeatCount == 0 -> if (!forward || slotsAhead < GUIDE_MAX_SLOTS_AHEAD) keepCursorInWindow(guideStep(channel, cursor, now, forward))
// Key repeats come quickly, so a day for every ten of them.
native.repeatCount % 10 == 0 -> keepCursorInWindow(guideDayJump(cursor, now, forward))
}
true
}
KeyEvent.KEYCODE_DPAD_LEFT -> { keepCursorInWindow(guideStep(channel, cursor, now, false)); true }
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> {
if (native.repeatCount == 0 && focused.startMs <= now) onWatch(channel.number, 0); true
}
@@ -178,6 +187,6 @@ internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modif
}
}
}
Text("▲▼ Channel ◄► Time OK Watch Back Home", color = Muted, fontSize = 11.sp)
Text("▲▼ Channel ◄► Time Hold ◄► Day OK Watch Back Home", color = Muted, fontSize = 11.sp)
}
}
@@ -42,10 +42,13 @@ internal class ScheduleStore(private val directory: File) {
private fun encodeCursor(cursor: ScheduleCursor) = JSONObject().put("last", cursor.lastSeriesId ?: JSONObject.NULL)
.put("marks", JSONObject().also { marks -> cursor.marks.forEach { (id, mark) -> marks.put(id, JSONArray().put(mark.season).put(mark.episode)) } })
.put("shown", JSONObject().also { shown -> cursor.filmShown.forEach { (id, at) -> shown.put(id, at) } })
private fun decodeCursor(o: JSONObject): ScheduleCursor {
val marks = o.getJSONObject("marks")
return ScheduleCursor(if (o.isNull("last")) null else validId(o.getString("last")),
marks.keys().asSequence().associate { id -> validId(id) to marks.getJSONArray(id).let { EpisodeMark(it.getInt(0), it.getInt(1)) } })
marks.keys().asSequence().associate { id -> validId(id) to marks.getJSONArray(id).let { EpisodeMark(it.getInt(0), it.getInt(1)) } },
// Absent in files saved before film channels existed.
o.optJSONObject("shown")?.let { shown -> shown.keys().asSequence().associate { id -> validId(id) to shown.getLong(id) } }.orEmpty())
}
private fun validId(id: String) = id.also { require(EmbyClient.validId(it)) }
private fun validDuration(ms: Long) = ms.also { require(it in 1_000..SampleChannel.MAX_DURATION_MS) }
@@ -53,7 +56,7 @@ internal class ScheduleStore(private val directory: File) {
fun savePlan(userId: String, number: Int, plan: ChannelPlan) {
val entries = JSONArray()
plan.entries.forEach { e -> entries.put(JSONObject().put("s", e.seriesId).put("n", JSONArray().put(e.mark.season).put(e.mark.episode))
.put("i", e.media.item.id).put("c", e.media.sourceId).put("t", e.media.item.title).put("d", e.media.durationMs).put("a", e.startMs)) }
.put("i", e.media.item.id).put("c", e.media.sourceId).put("t", e.media.item.title).put("d", e.media.durationMs).put("a", e.startMs).also { row -> if (e.media.item.type == "Movie") row.put("k", "Movie") }) }
write(file("schedule", number), root(userId, number).put("tail", encodeCursor(plan.tail)).put("entries", entries))
}
fun loadPlan(userId: String, number: Int): ChannelPlan? = read(file("schedule", number), userId, number) { root ->
@@ -62,7 +65,7 @@ internal class ScheduleStore(private val directory: File) {
val o = rows.getJSONObject(index)
val duration = validDuration(o.getLong("d"))
val mark = o.getJSONArray("n").let { EpisodeMark(it.getInt(0), it.getInt(1)) }
val item = SampleItem(o.getString("t"), "Episode", duration / 60_000, "", validId(o.getString("i")), duration)
val item = SampleItem(o.getString("t"), o.optString("k", "Episode").also { require(it in listOf("Episode", "Movie")) }, duration / 60_000, "", validId(o.getString("i")), duration)
ScheduleEntry(validId(o.getString("s")), mark, ScheduledMedia(item, validId(o.getString("c")), duration), o.getLong("a"))
}
require(entries.zipWithNext().all { (first, second) -> first.endMs <= second.startMs })
@@ -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.39.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.40.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()) {
@@ -220,4 +220,80 @@ class ChannelScheduleTest {
assertEquals("", guideDayPrefix(now, now))
assertTrue(guideDayPrefix(now + 3 * day, now).isNotEmpty())
}
private fun film(id: String, minutes: Long = 100) = PoolSeries(id, "Film $id", listOf(PoolEpisode(id, "src-$id", EpisodeMark(1, 1), "Film $id", minutes * minute)))
private val films = (1..40).map { film("f$it", 80L + it) }
private val rest = 48 * 60 * minute
@Test fun filmsKeepTheirRestPeriodWhenThePoolAllowsIt() {
val entries = extendSchedule(films, ScheduleCursor(), now, now + 7 * day, rest).entries
entries.groupBy { it.seriesId }.values.forEach { showings ->
showings.zipWithNext().forEach { (a, b) -> assertTrue(b.startMs - a.startMs >= rest) }
}
assertTrue(entries.zipWithNext().all { (a, b) -> a.endMs == b.startMs })
assertTrue(entries.all { it.media.item.type == "Movie" && !it.media.item.title.contains(" / ") })
}
@Test fun aSmallFilmPoolStillFillsThePlanWithWhatHasWaitedLongest() {
val two = listOf(film("a"), film("b"))
val entries = extendSchedule(two, ScheduleCursor(), now, now + day, rest).entries
assertEquals(listOf("a", "b", "a", "b").toSet(), entries.map { it.seriesId }.toSet())
assertTrue(entries.zipWithNext().all { (x, y) -> x.seriesId != y.seriesId })
}
@Test fun filmOrderIsNotASimpleLoopButIsReproducible() {
val first = extendSchedule(films, ScheduleCursor(), now, now + 7 * day, rest).entries.map { it.seriesId }
assertEquals(first, extendSchedule(films, ScheduleCursor(), now, now + 7 * day, rest).entries.map { it.seriesId })
val cycle = first.take(40)
assertNotEquals(films.map { it.id }, cycle)
}
@Test fun filmRestSurvivesASaveAndAnExtension() {
val store = store()
val plan = topUpPlan(null, ScheduleCursor(), films, now, rest)!!
store.savePlan(user, 102, plan)
val read = store.loadPlan(user, 102)!!
assertEquals(plan, read)
val later = topUpPlan(read, ScheduleCursor(), films, now + 2 * day, rest)!!
later.entries.groupBy { it.seriesId }.values.forEach { showings ->
showings.zipWithNext().forEach { (a, b) -> assertTrue(b.startMs - a.startMs >= rest) }
}
}
@Test fun aSavedPlanFromBeforeFilmsStillOpens() {
val dir = folder.newFolder()
val store = ScheduleStore(dir)
store.savePlan(user, 152, topUpPlan(null, ScheduleCursor(), pool, now)!!)
val file = java.io.File(dir, "schedule-152.json")
val root = JSONObject(file.readText())
root.getJSONObject("tail").remove("shown")
file.writeText(root.toString())
assertNotNull(store.loadPlan(user, 152))
}
@Test fun airedFilmsAreRemembered() {
val plan = extendSchedule(films, ScheduleCursor(), now, now + day, rest)
val aired = airedCursor(ScheduleCursor(), plan.entries, plan.entries[2].startMs)
assertEquals(plan.entries[2].startMs, aired.filmShown[plan.entries[2].seriesId])
assertEquals(3, aired.filmShown.size)
}
@Test fun onlyAnExactFilmTitleCounts() {
val rows = JSONArray()
.put(JSONObject().put("Id", "sequel").put("Type", "Movie").put("Name", "Halloween II").put("ProductionYear", 1981).put("RunTimeTicks", 60_000_000_000L)
.put("MediaSources", JSONArray().put(JSONObject().put("Id", "s1").put("Container", "mkv"))))
.put(JSONObject().put("Id", "remake").put("Type", "Movie").put("Name", "Halloween").put("ProductionYear", 2007).put("RunTimeTicks", 60_000_000_000L)
.put("MediaSources", JSONArray().put(JSONObject().put("Id", "s2").put("Container", "mkv"))))
.put(JSONObject().put("Id", "original").put("Type", "Movie").put("Name", "Halloween").put("ProductionYear", 1978).put("RunTimeTicks", 60_000_000_000L)
.put("MediaSources", JSONArray().put(JSONObject().put("Id", "s3").put("Container", "mkv"))))
val transport = EmbyTransport { EmbyReply(200, JSONObject().put("Items", rows).toString()) }
assertEquals("original", findFilm(transport, user, { "t" }, "Halloween")?.id)
assertNull(findFilm(transport, user, { "t" }, "Halloween Ends"))
}
@Test fun aDayJumpStaysBetweenNowAndAWeekAhead() {
assertEquals(now + day, guideDayJump(now, now, true))
assertEquals(now, guideDayJump(now + 60 * minute, now, false))
assertTrue(guideDayJump(now + 7 * day, now, true) < now + 7 * day)
}
}
+10 -1
View File
@@ -71,7 +71,16 @@ 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) is built and on the box as 0.39, on Paradox only, awaiting Paul's review. See VERIFICATION-0.39.0.md. Phases B to D (Terra and After Dark, Vortex, the remaining channels) need separate approval. 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) is built as 0.40, in testing. Phases C and D (Vortex, the remaining channels) need separate approval. Animated idents are design files only and aren't in the app yet.
## Programming director
Paul's direction, 2 November 2026, for after broadcast reliability is proven. Proposed, not built.
- **Two tracks:** broadcast reliability (episode progression, schedule recovery, film rotation, day parts) comes first. Smart programming (channel identity rules, discovery, variety, explainable recommendations, and eventually local learning) follows.
- **Two parts:** the scheduler executes a stable programme plan. A separate programming director decides what belongs in that plan.
- **Personality setting:** Familiar (more established favourites), Balanced (favourites mixed with discoveries), Surprise Me (more unusual programming). Illustrative, not in the app.
- **Ownership:** rule-based and transparent, with lightweight preference signals. Learning can be switched off entirely and reset. No cloud, no mandatory AI, no uploading viewing history.
## Future polish ideas