Add channel idents, with the next show loaded ahead (0.78.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m35s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m36s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-02 15:04:12 +00:00
co-authored by Claude Opus 5.5
parent 847c9a108d
commit 181ba09e51
35 changed files with 225 additions and 8 deletions
+9
View File
@@ -0,0 +1,9 @@
# Orbit TV 0.78.0: idents
- Full screen, the last 7 seconds of a programme are covered by the channel's approved 3D ident, with "Next" and the coming show. The show underneath fades to silence and pauses. It covers the end of a show, usually credits, so the next one still starts exactly on time and schedules don't change.
- As Paul asked, the next show loads silently first, 12 seconds before the ident begins, and takes over when it ends. If it isn't ready, it tunes in the ordinary way.
- 25 channels have a film, re-encoded small: 8 MB in all, taking the app to 21.6 MB. Wild, Detour, BritX and Junior have none yet, so they show their logo over the next show's artwork for 5 seconds.
- No ident on programmes under 3 minutes, in the picture on the menu, or on the Drive-In.
- Idents are silent for now.
Checked: build, lint and 294 unit tests pass. Not checked until installed: a real changeover on the box, including whether there's a black moment as the next show takes over, how it looks, and a second Emby stream open for about 20 seconds against the plan's limit of 3.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 84
versionName = "0.77.0-snake-sprites-music"
versionCode = 85
versionName = "0.78.0-idents"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -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.77.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.78.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -41,7 +41,9 @@ 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, onUnplayable: ((String) -> Unit)? = null, audioPreference: MutableState<String?>? = 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, audioPreference: MutableState<String?>? = null,
/** Set while an ident covers the end of the show: drawn over everything, with the show itself quietened underneath. */
ident: (@Composable () -> Unit)? = null) {
val context = LocalContext.current
var failure by remember { mutableStateOf<String?>(null) }
var tuningNumberVisible by remember { mutableStateOf(announce) }
@@ -129,6 +131,11 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
// A player handed over from the picture is already on schedule and may never report ready again.
var joinedSchedule = runningPlayer != null
if (player.playbackState == Player.STATE_READY) ready = true
// Its tracks were found before this listener arrived, so the subtitle choice is made here.
if (runningPlayer != null && subtitleIndex == null) {
val available = textTracks()
subtitleFor(subtitlePreference?.value, available.map { it.getTrackFormat(0).language.orEmpty() })?.let { chooseSubtitle(it, available) }
}
playing = player.isPlaying
val listener = object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
@@ -190,6 +197,13 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
LaunchedEffect(player, ready) {
if (ready) { delay(2_000); tuningNumberVisible = false }
}
val covered = ident != null
LaunchedEffect(player, covered) {
if (covered) {
repeat(10) { step -> player.volume = 1f - (step + 1) / 10f; delay(50) }
player.pause()
}
}
LaunchedEffect(player, ready) {
if (ready && foreground) {
val startPosition = player.currentPosition; val startPaused = !player.isPlaying
@@ -307,6 +321,7 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
Image(painterResource(bug), null, Modifier.align(Alignment.BottomStart).padding(start = 40.dp, bottom = 28.dp).width(108.dp).height(36.dp), alpha = .8f)
if (infoVisible && failure == null && ready) ClassicProgrammeStrip(plan.item, playing,
Modifier.align(Alignment.BottomCenter).fillMaxWidth(.86f).padding(bottom = 26.dp), scheduled = scheduled)
ident?.invoke()
trackMenu?.let { TrackPanel(it, trackCursor, Modifier.align(Alignment.CenterEnd).padding(end = 28.dp)) }
numberEntry?.let { ChannelNumberBox(it, numberProblem, Modifier.align(Alignment.TopCenter).padding(top = 44.dp)) }
if (failure != null) Column(Modifier.align(Alignment.Center).widthIn(max = 620.dp).background(Navy).padding(24.dp)) {
@@ -0,0 +1,107 @@
package tv.orbit.classic
import android.content.Context
import android.view.TextureView
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.RawResourceDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.tv.material3.Text
internal const val IDENT_VIDEO_MS = 7_000L
internal const val IDENT_STING_MS = 5_000L
/** Long enough to open the next show and fill its buffer before the ident starts. */
internal const val IDENT_LOAD_AHEAD_MS = 12_000L
private const val IDENT_SHORTEST_PROGRAMME_MS = 3 * 60_000L
private val IdentVideos = mapOf(101 to R.raw.ident_101, 102 to R.raw.ident_102, 103 to R.raw.ident_103, 104 to R.raw.ident_104, 105 to R.raw.ident_105, 106 to R.raw.ident_106, 107 to R.raw.ident_107,
201 to R.raw.ident_201, 202 to R.raw.ident_202, 204 to R.raw.ident_204, 301 to R.raw.ident_301, 302 to R.raw.ident_302, 303 to R.raw.ident_303, 304 to R.raw.ident_304, 305 to R.raw.ident_305,
306 to R.raw.ident_306, 307 to R.raw.ident_307, 401 to R.raw.ident_401, 601 to R.raw.ident_601, 602 to R.raw.ident_602, 603 to R.raw.ident_603, 604 to R.raw.ident_604, 605 to R.raw.ident_605,
612 to R.raw.ident_612, 613 to R.raw.ident_613)
internal fun identVideo(number: Int) = IdentVideos[number]
internal fun identLength(number: Int) = if (number in IdentVideos) IDENT_VIDEO_MS else IDENT_STING_MS
internal enum class IdentPhase { None, LoadNext, Showing }
/** The ident covers the end of a show, usually its credits, so the next one still starts exactly on time. */
internal fun identPhase(startMs: Long, endMs: Long, nowMs: Long, identMs: Long): IdentPhase = when {
endMs - startMs < IDENT_SHORTEST_PROGRAMME_MS -> IdentPhase.None
nowMs >= endMs -> IdentPhase.None
nowMs >= endMs - identMs -> IdentPhase.Showing
nowMs >= endMs - identMs - IDENT_LOAD_AHEAD_MS -> IdentPhase.LoadNext
else -> IdentPhase.None
}
/** The next show, opened and buffering with nothing on screen, ready to take over when the ident ends. */
internal class LoadedNext(val startMs: Long, val plan: EmbyPlaybackPlan, private val player: ExoPlayer) {
private var claimed = false
fun claim(): ExoPlayer? = if (claimed) null else player.also { claimed = true; it.play() }
fun discard(session: EmbySampleSession) {
if (claimed) return
claimed = true
player.release()
EmbyCleanup.run { session.report(plan, "Playing/Stopped", 0, true) }
}
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
internal fun loadNextPlayer(context: Context, session: EmbySampleSession, plan: EmbyPlaybackPlan, audio: String?): ExoPlayer {
val factory = DataSource.Factory { SecureMediaDataSource(session) }
return ExoPlayer.Builder(context).setMediaSourceFactory(DefaultMediaSourceFactory(factory)).setSeekBackIncrementMs(10_000).setSeekForwardIncrementMs(10_000).build().apply {
setMediaItem(channelMediaItem(plan))
trackSelectionParameters = trackSelectionParameters.buildUpon().setPreferredAudioLanguage(audio ?: "en").build()
prepare(); playWhenReady = false
}
}
/** The channel's ident over the end of the show, saying what's next. A channel with no film of its own gets its logo over the next show's artwork. */
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@Composable
internal fun IdentOverlay(session: EmbySampleSession, channel: SampleChannel, next: SampleBroadcast, identMs: Long, startedLateMs: Long) {
val context = LocalContext.current
val fade = remember { Animatable(0f) }
LaunchedEffect(Unit) { fade.animateTo(1f, tween(400)) }
Box(Modifier.fillMaxSize().graphicsLayer { alpha = fade.value }.background(Navy)) {
val video = identVideo(channel.number)
if (video != null) {
val player = remember(video) {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(RawResourceDataSource.buildRawResourceUri(video)))
seekTo(startedLateMs.coerceIn(0, identMs))
volume = 0f; prepare(); playWhenReady = true
}
}
DisposableEffect(player) { onDispose { player.release() } }
// Drawn in the window itself, so it can sit over the show's own picture.
AndroidView(modifier = Modifier.fillMaxSize(), factory = { TextureView(it).also(player::setVideoTextureView) })
} else {
ProgrammeArtwork(session, channel, next)
ChannelBrands.forNumber(channel.number)?.let { brand ->
Image(painterResource(brand.logo), brand.displayName, Modifier.align(Alignment.Center).width(400.dp).height(125.dp))
}
}
Column(Modifier.align(Alignment.BottomStart).padding(start = 40.dp, bottom = 32.dp).background(Navy.copy(alpha = .72f)).padding(horizontal = 14.dp, vertical = 8.dp)) {
Text("NEXT", color = Color(0xFFD5DCEA), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
Text(guideTitleParts(next.media.item.title).first, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
@@ -52,6 +52,26 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC
if (stream != null && stream.channelNumber == channel.number && stream.startMs == current.startMs && clockOffsetMs == 0L) stream
else { stream?.discard(session); null }
}
val next = shows[1]
val identMs = identLength(channel.number)
val identsHere = channel is ScheduledChannel && clockOffsetMs == 0L && channel.number != DRIVE_IN_NUMBER && next.startMs == current.endMs
val phase = if (identsHere) identPhase(current.startMs, current.endMs, now, identMs) else IdentPhase.None
val playerContext = androidx.compose.ui.platform.LocalContext.current
var loaded by remember(channel) { mutableStateOf<LoadedNext?>(null) }
DisposableEffect(channel) { onDispose { loaded?.discard(session) } }
// A problem here only means the next show tunes in the ordinary way.
LaunchedEffect(channel, current.startMs, phase == IdentPhase.None) {
if (phase == IdentPhase.None || loaded?.startMs == next.startMs) return@LaunchedEffect
try {
val opened = withContext(Dispatchers.IO) { session.prepareForTuning(next.media.item, next.media.sourceId) { Thread.sleep(2_000) } }
if (scheduledRuntimeMatches(opened.durationMs, next.media.durationMs)) {
loaded?.discard(session)
loaded = LoadedNext(next.startMs, opened, loadNextPlayer(playerContext, session, opened, audioPreference.value))
android.util.Log.i("OrbitSchedule", "channel=${channel.number} nextLoadedAheadMs=${next.startMs - System.currentTimeMillis()}")
}
} catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { }
}
val loadedNow = loaded?.takeIf { it.startMs == current.startMs }
val prepareMutex = remember(channel) { Mutex() }
val handedNow = handed?.takeIf { it.available && it.channelNumber == channel.number }
var prepared by remember(channel) { mutableStateOf(handedNow?.plan) }
@@ -61,6 +81,7 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC
// A new wall-clock slot disposes the old player before opening its successor.
LaunchedEffect(channel, current.startMs, retryAttempt) {
if (handed != null && prepared === handed.plan && preparedSlot == current.startMs) return@LaunchedEffect
if (loadedNow != null) return@LaunchedEffect
prepared = null; preparedSlot = null; problem = null
try {
val tuneStarted = android.os.SystemClock.elapsedRealtime()
@@ -78,13 +99,14 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC
catch (_: Exception) { problem = if (channel is ScheduledChannel) "This programme isn't available right now. Orbit moves on at ${clock(current.endMs)}." else "This scheduled programme could not be opened. Return to the guide and try again." }
}
// Suppress the preceding plan immediately at a boundary while the effect schedules its replacement.
val plan = prepared?.takeIf { it.item.id == current.media.item.id && preparedSlot == current.startMs && problem == null }
val plan = loadedNow?.plan ?: prepared?.takeIf { it.item.id == current.media.item.id && preparedSlot == current.startMs && problem == null }
if (plan != null) {
key(current.startMs) {
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, onUnplayable = onUnplayable, audioPreference = audioPreference,
scheduled = ScheduledPlayback(channel, current, shows[1], clockOffsetMs), onStep = onStep, onLast = onLast, onNumber = onNumber, subtitlePreference = subtitlePreference, runningPlayer = loadedNow?.claim() ?: handed?.takeIf { it.plan === plan }?.claim(),
ident = if (phase == IdentPhase.Showing) { { IdentOverlay(session, channel, next, identMs, now - (current.endMs - identMs)) } } else null, announce = announce, onUnplayable = onUnplayable, audioPreference = audioPreference,
onHandBack = { running ->
if (handoff != null && clockOffsetMs == 0L) { handoff.returned = LiveStream(channel.number, current.startMs, plan, running); true } else false
})
@@ -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.77.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.78.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()) {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,32 @@
package tv.orbit.classic
import org.junit.Assert.*
import org.junit.Test
class IdentTest {
private val minute = 60_000L
private val start = 0L
private val end = 30 * minute
@Test fun theIdentCoversTheVeryEndOfAShow() {
assertEquals(IdentPhase.None, identPhase(start, end, end - IDENT_VIDEO_MS - IDENT_LOAD_AHEAD_MS - 1, IDENT_VIDEO_MS))
assertEquals(IdentPhase.LoadNext, identPhase(start, end, end - IDENT_VIDEO_MS - IDENT_LOAD_AHEAD_MS, IDENT_VIDEO_MS))
assertEquals(IdentPhase.Showing, identPhase(start, end, end - IDENT_VIDEO_MS, IDENT_VIDEO_MS))
assertEquals(IdentPhase.Showing, identPhase(start, end, end - 1, IDENT_VIDEO_MS))
assertEquals(IdentPhase.None, identPhase(start, end, end, IDENT_VIDEO_MS))
}
@Test fun aVeryShortProgrammeGetsNoIdent() {
assertEquals(IdentPhase.None, identPhase(start, 2 * minute, 2 * minute - 1, IDENT_VIDEO_MS))
}
@Test fun channelsWithoutAFilmUseAShorterSting() {
assertEquals(IDENT_VIDEO_MS, identLength(101))
assertNull(identVideo(308))
assertEquals(IDENT_STING_MS, identLength(308))
}
@Test fun everyIdentBelongsToARealChannel() {
SmartChannels.map { it.number }.filter { identVideo(it) == null }.let { assertEquals(listOf(203, 308, 309, 611), it.sorted()) }
}
}
+32
View File
@@ -0,0 +1,32 @@
#!/bin/bash
# The app's copies of the channel idents: the approved 3D versions, re-encoded small and silent, named by channel number.
D=${1:?folder holding depth-native}; OUT=$(dirname "$0")/../../android/app/src/main/res/raw
while read -r number source; do
ffmpeg -nostdin -y -loglevel error -i "$D/$source" -an -vf fps=30 -c:v libx264 -profile:v main -pix_fmt yuv420p -crf 30 -maxrate 1400k -bufsize 2800k -movflags +faststart "$OUT/ident_$number.mp4"
done <<'LIST'
101 world/orbit-1/orbit-1_3d.mp4
102 world/orbit-2/orbit-2_3d.mp4
103 world/orbit-three/orbit-three_3d.mp4
104 world/orbit-gold/orbit-gold_3d.mp4
105 world/orbit-riot/orbit-riot_3d.mp4
106 world/paradox/paradox_3d.mp4
107 world/evidence/evidence_3d.mp4
201 world/orbit-terra/orbit-terra_3d.mp4
202 world/orbit-odyssey/orbit-odyssey_3d.mp4
204 world/orbit-dark-files/orbit-dark-files_3d.mp4
301 world/orbit-premiere/orbit-premiere_3d.mp4
302 world/orbit-action/orbit-action_3d.mp4
303 world/orbit-sci-fi/orbit-sci-fi_3d.mp4
304 world/orbit-horror/orbit-horror_3d.mp4
305 world/orbit-after-dark/orbit-after-dark_3d.mp4
306 world/orbit-vhs/orbit-vhs_3d.mp4
307 world/orbit-romance/orbit-romance_3d.mp4
401 world/orbit-ring-x/orbit-ring-x_3d.mp4
601 world/vortex/vortex_3d.mp4
602 city/heroes/heroes_city.mp4
603 world/orbit-x/orbit-x_3d.mp4
604 world/rewind/rewind_3d.mp4
605 world/toonz/toonz_3d.mp4
612 world/wonder/wonder_3d.mp4
613 world/orbit-pixelz/orbit-pixelz_3d.mp4
LIST
+1 -1
View File
@@ -94,7 +94,7 @@ Paul's ideas, 1 November 2026. Not fixes and not scheduled.
- **Rings around the planet (Paul, 1 November 2026):** Orbit 1's background had its rings off to one side of the planet. The app's copy is fixed, from orbit-1/sources/orbit_1_background_v2.svg. Orbit 1's ident and the other channels built from the same planet layout (Orbit 2, Orbit Sci-Fi and any others on that theme) still need checking and the same fix before they join the app.
- **Bigger film pools (Paul, 2 November 2026):** filling by rating in 0.74 picked poor titles, so 0.75 uses longer hand-picked lists instead. A real programming director needs a better signal than rating, to be ironed out with Paul.
- **Build your own network (Paul, 2 November 2026):** for anyone else using the app. On first sign-in, Orbit checks our channels against their library and ticks the ones it can fill, with those channels on by default. They can then edit any channel, adding or removing titles, turn channels on or off, and create new channels of their own. It's about people building what they want, as Paul did. In the pipeline, not scheduled.
- **Seamless idents (Paul, 2 November 2026):** when idents arrive, the next show loads silently behind the ident and takes over as it ends, so there's no loading gap. To test: whether the box can decode two videos at once (otherwise draw the ident in the app), and a second Emby stream for a few seconds against the plan's limit of 3.
- **Seamless idents (Paul, 2 November 2026):** in 0.78, the ident covers the last 7 seconds of a show, and the next one loads silently first and takes over on time. Wild, Detour, BritX and Junior still need films of their own.
- **Settings (Paul, 2 November 2026):** built in 0.72, then cut back in 0.73 to Channels, Watching and Emby account, as Paul wants only what's mandatory and a simple flow. The demo guide, old menu and test tools are gone.
- **Subtitles default (Paul, 1 November 2026):** a setting behind the gear for subtitles on or off by default, in the viewer's language where a programme has it.
- **Orbit Drive-In name (Paul, 1 November 2026):** decided, replacing OrbitOffice. Event types inside it are Orbit Marquee, Orbit Double Bill, Orbit Triple Bill and The Late Show. The briefs and design/interface still show the old name.