Leave out files the box can't decode, and remember ones that fail (0.61.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 2m10s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m27s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-02 10:29:35 +00:00
co-authored by Claude Opus 5.5
parent 10424d3633
commit 5dece25a6a
16 changed files with 171 additions and 33 deletions
+20
View File
@@ -0,0 +1,20 @@
# Orbit TV 0.61.0 verification
Handling files this box can't play, without transcoding, which Paul's Emby server doesn't allow.
## Changed
- 0.60's transcoding fallback is removed. It never reached the box.
- When reading a show or film, the app compares each file's video format with the decoders on this box, and leaves out what it can't decode. Unknown formats are kept.
- A file that fails again after one retry, or plays with no video decoder, is recorded and never planned again. The channel shows "This programme won't play on this box. Orbit moves on at..." until the next programme.
- The small picture shows artwork instead of sound with no picture. A passing fault there doesn't record the file.
- Each channel reads its list once more, to apply the format check.
## Checked
- assembleDebug, testDebugUnitTest and lintDebug pass; 246 tests, 0 failures. Tests cover format matching, leaving out files and the failed list.
- Reproduced on the box with 0.59: Transformers S1 E1 fails every time, c2.android.mp3.decoder Error 0xe.
- APK signature verified with apksigner.
## Not checked
- Not yet run on the box. Forensic Files' video format is unconfirmed, and this build logs it.
- Programmes already in a plan stay there until the plan rolls on, so a bad file can still come up in the current week. A rebuild would clear them.
- There's no way yet to give a failed file another chance.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 60
versionName = "0.59.0-playback-retry"
versionCode = 62
versionName = "0.61.0-unplayable-files"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -0,0 +1,32 @@
package tv.orbit.classic
import android.media.MediaCodecList
/** What Emby calls a video format, as the box's decoders name it. A format not listed here is given the benefit of the doubt. */
internal fun videoMimeFor(codec: String): String? = when (codec.trim().lowercase(java.util.Locale.ROOT)) {
"h264", "avc" -> "video/avc"
"hevc", "h265" -> "video/hevc"
"vp8" -> "video/x-vnd.on2.vp8"
"vp9" -> "video/x-vnd.on2.vp9"
"av1" -> "video/av01"
"mpeg2video", "mpeg2" -> "video/mpeg2"
"mpeg4", "msmpeg4v3", "divx", "xvid" -> "video/mp4v-es"
"h263" -> "video/3gpp"
"vc1", "wmv3" -> "video/wvc1"
else -> null
}
/** The video format of a media source, if it says. */
internal fun sourceVideoCodec(source: org.json.JSONObject): String? {
val streams = source.optJSONArray("MediaStreams") ?: return null
return (0 until minOf(streams.length(), 32)).mapNotNull { streams.optJSONObject(it) }
.firstOrNull { it.optString("Type") == "Video" }?.optString("Codec")?.takeIf { it.isNotBlank() }
}
/** The decoders this box actually has, read once. */
internal object BoxDecoders {
private val types by lazy {
MediaCodecList(MediaCodecList.ALL_CODECS).codecInfos.filterNot { it.isEncoder }.flatMap { it.supportedTypes.asList() }.map { it.lowercase(java.util.Locale.ROOT) }.toSet()
}
fun canDecode(codec: String): Boolean = videoMimeFor(codec)?.let { it in types } ?: true
}
@@ -99,7 +99,7 @@ internal data class ChannelPlan(val entries: List<ScheduleEntry>, val tail: Sche
}
/** Bump when the way pools are read changes, so each channel reads its once again. */
private const val CHANNEL_POOL_VERSION = 2
private const val CHANNEL_POOL_VERSION = 3
internal const val SCHEDULE_AHEAD_MS = 7 * 24 * 60 * 60_000L
internal const val SCHEDULE_TOP_UP_MS = 6 * 24 * 60 * 60_000L
@@ -131,6 +131,10 @@ internal fun turnOrder(pool: List<PoolSeries>, last: String?): List<PoolSeries>
return (0 until pool.size).map { pool[(after + it) % pool.size] }
}
/** A pool without files known not to play. A show with nothing left drops out. */
internal fun withoutFailed(pool: List<PoolSeries>, failed: Set<String>): List<PoolSeries> =
if (failed.isEmpty()) pool else pool.map { show -> show.copy(episodes = show.episodes.filter { it.id !in failed }) }.filter { it.episodes.isNotEmpty() }
/** The next show after [last] in the rota that [eligible] allows. */
internal fun nextInTurn(pool: List<PoolSeries>, last: String?, eligible: (PoolSeries) -> Boolean): PoolSeries {
return turnOrder(pool, last).first(eligible)
@@ -26,6 +26,11 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
store.saveProgress(userId, number, ScheduleStore.SavedProgress(enabled, progress?.aired ?: ScheduleCursor()))
}
/** A file that wouldn't play. It stays in what's already planned, and is left out from here on. */
fun markFailed(userId: String, itemId: String) {
store.saveFailed(userId, store.loadFailed(userId) + itemId)
}
/** Starts a channel that has no loop yet: reads its pool, then gives it a small fallback made from that pool. */
fun seedLoop(session: EmbySampleSession, userId: String, programming: ChannelProgramming, checkActive: () -> Unit = {}): SampleChannel {
val choices = store.loadChoices(userId, programming.number)
@@ -69,7 +74,7 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
val base = progress?.aired ?: ScheduleCursor()
// 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,
val plan = topUpPlan(existing, base, pool?.series?.let { withoutFailed(it, store.loadFailed(userId)) }, 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)))
@@ -34,10 +34,10 @@ internal const val SUGGESTION_MAX_PAGES = 3
internal class SuggestionRead(val suggestions: List<Suggestion>, val requests: Int)
/** The first playable copy of a title, as (source id, runtime). */
internal fun playableSource(row: org.json.JSONObject): Pair<String, Long>? {
internal fun playableSource(row: org.json.JSONObject, canDecode: (String) -> Boolean = { true }): Pair<String, Long>? {
val sources = row.optJSONArray("MediaSources") ?: return 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 null
.firstOrNull { supportedDirectContainer(it.optString("Container")) != null && EmbyClient.validId(it.optString("Id")) && sourceVideoCodec(it)?.let(canDecode) != false } ?: return 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 null
return source.getString("Id") to duration
@@ -45,7 +45,7 @@ internal fun playableSource(row: org.json.JSONObject): Pair<String, Long>? {
/** Highest rated first, in bounded pages, one rule at a time. A title is never suggested twice, and a rejected one never again. */
internal fun findSuggestions(transport: EmbyTransport, userId: String, token: () -> String, rules: List<SuggestionRule>,
alreadyInPool: Set<String>, rejected: Set<String>, checkActive: () -> Unit = {}): SuggestionRead {
alreadyInPool: Set<String>, rejected: Set<String>, checkActive: () -> Unit = {}, canDecode: (String) -> Boolean = { true }): SuggestionRead {
require(EmbyClient.validId(userId))
val found = LinkedHashMap<String, Suggestion>()
val seen = mutableSetOf<String>()
@@ -78,7 +78,7 @@ internal fun findSuggestions(transport: EmbyTransport, userId: String, token: ()
if (score > rule.maxRating) continue
if (row.optString("OfficialRating") in rule.excludeRatings) continue
if (!seen.add("${seriesKey(title)}#$year")) continue
val source = if (rule.type == "Movie") playableSource(row) ?: continue else null
val source = if (rule.type == "Movie") playableSource(row, canDecode) ?: continue else null
if (source != null && source.second < rule.minMinutes * 60_000L) continue
val reason = "${rule.label}: ${rule.genres.joinToString(" and ")}, ${year ?: "year unknown"}, rated ${"%.1f".format(java.util.Locale.UK, score)}"
found[id] = Suggestion(id, title, year, rule.type, rule.group, reason, source?.first, source?.second)
@@ -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.59.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.61.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -278,6 +278,10 @@ 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
fun rememberUnplayable(itemId: String) {
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 liveChannel = availableChannels.firstOrNull { it.number == selectedNumber }
if ((mainGuide || mainHome) && liveSession != null && liveChannel != null) {
@@ -295,13 +299,13 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
val exists = availableChannels.any { it.number == number }
if (exists && number != selectedNumber) { lastNumber = selectedNumber; selectedNumber = number; channelClockOffset = 0 }
exists
}, handoff = liveHandoff)
}, handoff = liveHandoff, onUnplayable = ::rememberUnplayable)
fun tune(number: Int, offset: Long) {
if (number != selectedNumber) { lastNumber = selectedNumber; selectedNumber = number }
if (offset == 0L) liveHandoff.handOver(number)
channelClockOffset = offset; watchingChannel = true
}
val picture = @Composable { GuidePicture(liveSession, liveChannel, live = !watchingChannel, handoff = liveHandoff) }
val picture = @Composable { GuidePicture(liveSession, liveChannel, live = !watchingChannel, handoff = liveHandoff, onUnplayable = ::rememberUnplayable) }
if (mainHome) HomeScreen(availableChannels, liveChannel, modifier, picture,
onCategory = { guideCategory = it; onGuide() }, onWatch = { tune(it, 0) }, onDriveIn = onDriveIn)
else GuideGrid(channelsIn(guideCategory, availableChannels).ifEmpty { availableChannels }, selectedNumber, modifier, guideCategory.label, picture,
@@ -35,12 +35,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
private const val MAX_PLAYER_RETRIES = 2
/** A decoder fault normally repeats on the same file, so one more try is enough. */
private const val MAX_PLAYER_RETRIES = 1
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@Composable
internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPlan, onClose: () -> Unit, scheduled: ScheduledPlayback? = null,
onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }, subtitlePreference: MutableState<String?>? = null, runningPlayer: ExoPlayer? = null, announce: Boolean = true, onHandBack: ((ExoPlayer) -> Boolean)? = null) {
onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }, subtitlePreference: MutableState<String?>? = null, runningPlayer: ExoPlayer? = null, announce: Boolean = true, onHandBack: ((ExoPlayer) -> Boolean)? = null, onUnplayable: ((String) -> Unit)? = null) {
val context = LocalContext.current
var failure by remember { mutableStateOf<String?>(null) }
var tuningNumberVisible by remember { mutableStateOf(announce) }
@@ -121,13 +122,14 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
val listener = object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
android.util.Log.w("OrbitSchedule", "channel=${scheduled?.channel?.number} playerError=${error.errorCodeName} retry=$playerRetries")
// A decoder can be taken away during a quick channel change, so a channel tries again before giving up.
// A decoder can also be taken away by a quick channel change, so a channel tries once more before giving up.
if (scheduled != null && playerRetries < MAX_PLAYER_RETRIES) {
ready = false
playerRetries++
return
}
if (scheduled != null) {
onUnplayable?.invoke(plan.item.id)
failure = "This programme won't play on this box. Orbit moves on at ${clock(scheduled.programme.endMs)}."
ready = false
return
@@ -138,6 +140,15 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
}
override fun onIsPlayingChanged(isPlaying: Boolean) { playing = isPlaying }
override fun onTracksChanged(tracks: Tracks) {
val video = tracks.groups.filter { it.type == C.TRACK_TYPE_VIDEO }
// Sound with no picture: the box can't decode this file's video.
if (scheduled != null && video.isNotEmpty() && video.none { it.isSupported }) {
android.util.Log.w("OrbitSchedule", "channel=${scheduled.channel.number} noVideoDecoder=${video.first().getTrackFormat(0).sampleMimeType}")
player.stop(); ready = false
onUnplayable?.invoke(plan.item.id)
failure = "This programme won't play on this box. Orbit moves on at ${clock(scheduled.programme.endMs)}."
return
}
if (subtitleIndex == null) {
val available = textTracks()
subtitleFor(subtitlePreference?.value, available.map { it.getTrackFormat(0).language.orEmpty() })?.let { chooseSubtitle(it, available) }
@@ -196,7 +196,7 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
if (pool.size >= programming.seriesCount) break
checkActive()
requests++
val film = findFilm(transport, userId, ::currentToken, title) ?: continue
val film = findFilm(transport, userId, ::currentToken, title, BoxDecoders::canDecode) ?: continue
if (pool.none { it.id == film.id }) pool += film
}
} else for (search in programming.searches) {
@@ -207,7 +207,7 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
val match = bestSeriesMatch(search, if (programming.exactTitles) candidates.filter { seriesKey(it.title) == seriesKey(search) } else candidates) ?: continue
// A second copy of a show in the library counts as the same show.
if (pool.any { it.id == match.id || seriesKey(it.title) == seriesKey(match.title) }) continue
val whole = readWholeSeries(transport, userId, ::currentToken, match, checkActive, onRequest = { requests++ })
val whole = readWholeSeries(transport, userId, ::currentToken, match, checkActive, onRequest = { requests++ }, canDecode = BoxDecoders::canDecode)
if (whole.episodes.isNotEmpty()) pool += whole
}
for (accepted in choices.accepted) {
@@ -218,14 +218,14 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
val duration = accepted.durationMs ?: continue
pool += PoolSeries(accepted.id, accepted.title, listOf(PoolEpisode(accepted.id, source, EpisodeMark(1, 1), accepted.title, duration)), accepted.group)
} else {
val whole = readWholeSeries(transport, userId, ::currentToken, SeriesCandidate(accepted.id, accepted.title, accepted.year, ""), checkActive, onRequest = { requests++ })
val whole = readWholeSeries(transport, userId, ::currentToken, SeriesCandidate(accepted.id, accepted.title, accepted.year, ""), checkActive, onRequest = { requests++ }, canDecode = BoxDecoders::canDecode)
if (whole.episodes.isNotEmpty()) pool += whole.copy(group = accepted.group)
}
}
return PoolRead(pool, requests)
}
fun suggestFor(rules: List<SuggestionRule>, alreadyInPool: Set<String>, rejected: Set<String>, checkActive: () -> Unit = {}) =
findSuggestions(transport, userId, ::currentToken, rules, alreadyInPool, rejected, checkActive)
findSuggestions(transport, userId, ::currentToken, rules, alreadyInPool, rejected, checkActive, BoxDecoders::canDecode)
fun fetchArtwork(path: String): ByteArray? = (transport as? HttpsEmbyTransport)?.fetchImage(path, currentToken())
fun rememberScheduledItems(items: List<SampleItem>) { channelEpisodes = channelEpisodes + items.associateBy { it.id } }
@@ -50,7 +50,7 @@ internal const val WHOLE_SERIES_TIMEOUT_MS = 60_000
/** A whole series, read in bounded pages one at a time: regular numbered episodes with a playable source, once each, in order. */
internal fun readWholeSeries(transport: EmbyTransport, userId: String, token: () -> String, series: SeriesCandidate,
checkActive: () -> Unit = {}, onRequest: () -> Unit = {}): PoolSeries {
checkActive: () -> Unit = {}, onRequest: () -> Unit = {}, canDecode: (String) -> Boolean = { true }): PoolSeries {
require(EmbyClient.validId(userId) && EmbyClient.validId(series.id))
val episodes = mutableListOf<PoolEpisode>()
var start = 0
@@ -68,28 +68,28 @@ internal fun readWholeSeries(transport: EmbyTransport, userId: String, token: ()
EmbyClient.expectSuccess(reply.status, false)
val body = EmbyClient.parse(reply.body)
val rows = body.optJSONArray("Items") ?: throw EmbyCheckException("The provider did not return the episode list.")
(0 until rows.length()).forEach { index -> rows.optJSONObject(index)?.let(::poolEpisode)?.let { episodes += it } }
(0 until rows.length()).forEach { index -> rows.optJSONObject(index)?.let { poolEpisode(it, canDecode) }?.let { episodes += it } }
start += rows.length()
if (rows.length() == 0 || start >= body.optInt("TotalRecordCount", 0)) break
}
return PoolSeries(series.id, series.title, episodes.sortedBy { it.mark }.distinctBy { it.mark })
}
private fun poolEpisode(item: org.json.JSONObject): PoolEpisode? {
private fun poolEpisode(item: org.json.JSONObject, canDecode: (String) -> Boolean): PoolEpisode? {
val id = item.optString("Id")
val season = item.optInt("ParentIndexNumber", -1)
val number = item.optInt("IndexNumber", -1)
if (item.optString("Type") != "Episode" || !EmbyClient.validId(id) || season < 1 || number < 1) return null
val sources = item.optJSONArray("MediaSources") ?: return 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 null
.firstOrNull { supportedDirectContainer(it.optString("Container")) != null && EmbyClient.validId(it.optString("Id")) && sourceVideoCodec(it)?.let(canDecode) != false } ?: return null
val ticks = source.optLong("RunTimeTicks", 0).takeIf { it > 0 } ?: item.optLong("RunTimeTicks", 0)
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? {
internal fun findFilm(transport: EmbyTransport, userId: String, token: () -> String, title: String, canDecode: (String) -> Boolean = { true }): 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",
@@ -105,7 +105,7 @@ internal fun findFilm(transport: EmbyTransport, userId: String, token: () -> Str
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
.firstOrNull { supportedDirectContainer(it.optString("Container")) != null && EmbyClient.validId(it.optString("Id")) && sourceVideoCodec(it)?.let(canDecode) != false } ?: 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)
@@ -61,7 +61,7 @@ internal fun channelMediaItem(plan: EmbyPlaybackPlan): MediaItem = MediaItem.Bui
* first frame and stays if the programme can't open. It holds one stream, so it stops when [live] goes false.
*/
@Composable
internal fun GuidePicture(session: EmbySampleSession, channel: SampleChannel, live: Boolean, handoff: LiveHandoff) {
internal fun GuidePicture(session: EmbySampleSession, channel: SampleChannel, live: Boolean, handoff: LiveHandoff, onUnplayable: (String) -> Unit = {}) {
val lifecycle = LocalLifecycleOwner.current.lifecycle
var foreground by remember { mutableStateOf(lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) }
DisposableEffect(lifecycle) {
@@ -76,9 +76,11 @@ internal fun GuidePicture(session: EmbySampleSession, channel: SampleChannel, li
LaunchedEffect(Unit) { while (true) { delay(1000); now = System.currentTimeMillis() } }
val current = channel.at(now)
var ready by remember(channel.number, current.startMs, live, foreground) { mutableStateOf(false) }
// A file that can't play here leaves the artwork up until the next programme.
var cantPlay by remember(channel.number, current.startMs) { mutableStateOf(false) }
Box(Modifier.fillMaxSize().background(Panel)) {
if (live && foreground) key(channel.number, current.startMs) {
GuideLiveProgramme(session, channel.number, current, handoff, onReady = { ready = true })
if (live && foreground && !cantPlay) key(channel.number, current.startMs) {
GuideLiveProgramme(session, channel.number, current, handoff, onReady = { ready = true }, onCantPlay = { id -> cantPlay = true; ready = false; id?.let(onUnplayable) })
}
val branding = ChannelBrands.forNumber(channel.number)
if (!ready) Box(Modifier.matchParentSize().background(Panel)) {
@@ -89,7 +91,7 @@ internal fun GuidePicture(session: EmbySampleSession, channel: SampleChannel, li
}
@Composable
private fun GuideLiveProgramme(session: EmbySampleSession, channelNumber: Int, programme: SampleBroadcast, handoff: LiveHandoff, onReady: () -> Unit) {
private fun GuideLiveProgramme(session: EmbySampleSession, channelNumber: Int, programme: SampleBroadcast, handoff: LiveHandoff, onReady: () -> Unit, onCantPlay: (String?) -> Unit) {
val returned = remember {
val stream = handoff.returned
handoff.returned = null
@@ -105,12 +107,12 @@ private fun GuideLiveProgramme(session: EmbySampleSession, channelNumber: Int, p
} catch (cancelled: CancellationException) { throw cancelled }
catch (_: Exception) { /* The artwork stays. Tuning full screen still explains any problem. */ }
}
plan?.let { GuideLivePlayer(session, it, channelNumber, programme, handoff, onReady, returned?.claim()) }
plan?.let { GuideLivePlayer(session, it, channelNumber, programme, handoff, onReady, onCantPlay, returned?.claim()) }
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@Composable
private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan, channelNumber: Int, programme: SampleBroadcast, handoff: LiveHandoff, onReady: () -> Unit, runningPlayer: ExoPlayer? = null) {
private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan, channelNumber: Int, programme: SampleBroadcast, handoff: LiveHandoff, onReady: () -> Unit, onCantPlay: (String?) -> Unit, runningPlayer: ExoPlayer? = null) {
val context = LocalContext.current
var ready by remember { mutableStateOf(false) }
val player = remember(session, plan) {
@@ -128,6 +130,18 @@ private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan,
var joinedSchedule = runningPlayer != null
if (player.playbackState == Player.STATE_READY) { ready = true; onReady() }
val listener = object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
android.util.Log.w("OrbitSchedule", "picture channel=$channelNumber playerError=${error.errorCodeName}")
// May just be a passing fault, so full screen gets to judge the file.
onCantPlay(null)
}
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
val video = tracks.groups.filter { it.type == androidx.media3.common.C.TRACK_TYPE_VIDEO }
if (video.isNotEmpty() && video.none { it.isSupported }) {
android.util.Log.w("OrbitSchedule", "picture channel=$channelNumber noVideoDecoder=${video.first().getTrackFormat(0).sampleMimeType}")
onCantPlay(plan.item.id)
}
}
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_READY) {
if (!joinedSchedule) {
@@ -90,7 +90,7 @@ internal fun scheduledRuntimeMatches(openedMs: Long?, scheduledMs: Long) = opene
@Composable
internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleChannel, clockOffsetMs: Long,
onClose: () -> Unit, onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }, handoff: LiveHandoff? = null) {
onClose: () -> Unit, onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }, handoff: LiveHandoff? = null, onUnplayable: ((String) -> Unit)? = null) {
// Outlives each programme and channel, and ends when the viewer leaves full screen.
val subtitlePreference = remember { mutableStateOf<String?>(null) }
var now by remember { mutableLongStateOf(System.currentTimeMillis() + clockOffsetMs) }
@@ -136,7 +136,7 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC
val announce = remember { announcedChannel[0] != channel.number }
SideEffect { announcedChannel[0] = channel.number }
EmbyPlaybackDialog(session, plan, onClose,
scheduled = ScheduledPlayback(channel, current, shows[1], clockOffsetMs), onStep = onStep, onLast = onLast, onNumber = onNumber, subtitlePreference = subtitlePreference, runningPlayer = handed?.takeIf { it.plan === plan }?.claim(), announce = announce,
scheduled = ScheduledPlayback(channel, current, shows[1], clockOffsetMs), onStep = onStep, onLast = onLast, onNumber = onNumber, subtitlePreference = subtitlePreference, runningPlayer = handed?.takeIf { it.plan === plan }?.claim(), announce = announce, onUnplayable = onUnplayable,
onHandBack = { running ->
if (handoff != null && clockOffsetMs == 0L) { handoff.returned = LiveStream(channel.number, current.startMs, plan, running); true } else false
})
@@ -99,6 +99,14 @@ internal class ScheduleStore(private val directory: File) {
}, root.getLong("fetched"), root.optString("signature"))
}
/** Files that wouldn't play on this box, across every channel. Never planned again. */
fun saveFailed(userId: String, ids: Set<String>) =
write(file("failed", 0), root(userId, 0).put("items", JSONArray(ids.toList())))
fun loadFailed(userId: String): Set<String> = read(file("failed", 0), userId, 0) { root ->
val items = root.getJSONArray("items")
(0 until items.length()).map { validId(items.getString(it)) }.toSet()
}.orEmpty()
/** Your accepted and rejected suggestions. Kept apart from the schedule, so a rebuild or a damaged plan never loses them. */
fun saveChoices(userId: String, number: Int, choices: ChannelChoices) {
val accepted = JSONArray()
@@ -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.59.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.61.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()) {
@@ -481,4 +481,44 @@ class ChannelScheduleTest {
val cells = guideCells(channel, start, start + 120 * minute)
assertEquals(cells.drop(1), guideWatchableCells(cells, now).drop(1))
}
private fun withVideo(id: String, number: Int, codec: String?) = episode(id, 1, number).also { row ->
val source = row.getJSONArray("MediaSources").getJSONObject(0)
if (codec != null) source.put("MediaStreams", JSONArray().put(JSONObject().put("Type", "Audio").put("Codec", "aac")).put(JSONObject().put("Type", "Video").put("Codec", codec)))
}
@Test fun episodesTheBoxCantDecodeAreLeftOut() {
val rows = JSONArray().put(withVideo("good", 1, "h264")).put(withVideo("bad", 2, "wmv3")).put(withVideo("unknown", 3, null))
val transport = EmbyTransport { EmbyReply(200, JSONObject().put("Items", rows).put("TotalRecordCount", rows.length()).toString()) }
val result = readWholeSeries(transport, user, { "t" }, series, canDecode = { it != "wmv3" })
assertEquals(listOf("good", "unknown"), result.episodes.map { it.id })
}
@Test fun formatNamesMapToDecoders() {
assertEquals("video/avc", videoMimeFor("H264"))
assertEquals("video/mp4v-es", videoMimeFor("mpeg4"))
assertNull(videoMimeFor("something-new"))
}
@Test fun aFailedFileIsLeftOutFromThenOn() {
val store = store()
store.saveFailed(user, setOf("a-1-1"))
val left = withoutFailed(pool, store.loadFailed(user))
assertFalse(left.flatMap { it.episodes }.any { it.id == "a-1-1" })
assertEquals(pool.size, left.size)
assertEquals(pool, withoutFailed(pool, emptySet()))
}
@Test fun aShowWithNothingLeftDropsOut() {
val only = listOf(show("z", 1, 1), show("y", 1, 1))
assertEquals(listOf("y"), withoutFailed(only, setOf("z-1-1")).map { it.id })
}
@Test fun failedFilesSurviveAndAreEmptyByDefault() {
val store = store()
assertTrue(store.loadFailed(user).isEmpty())
store.saveFailed(user, setOf("a"))
store.saveFailed(user, store.loadFailed(user) + "b")
assertEquals(setOf("a", "b"), store.loadFailed(user))
}
}