Hand the picture's stream to full screen instead of re-tuning (0.31.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m53s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m18s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-01 23:14:13 +00:00
co-authored by Claude Opus 5.5
parent e69606950f
commit 872989eb2a
8 changed files with 82 additions and 26 deletions
+16
View File
@@ -0,0 +1,16 @@
# Orbit TV 0.31.0 verification
Going full screen on the channel already in the picture no longer re-tunes. Paul noticed the reload on 0.29.
## Changed
- The picture slot's open stream is handed to the full-screen player when the viewer goes full screen on that channel, from the menu or the guide. No new request goes to the provider and the sound should carry on.
- The picture's stream now includes external subtitles, so they are still there after the handover.
- Other channels, the transition test and a handover at a programme boundary still tune as before, and any stream that isn't taken is closed.
## Checked
- assembleDebug, testDebugUnitTest and lintDebug pass; 138 tests, 0 failures. None of these exercise the handover, which needs real playback.
- APK signature verified with apksigner.
## Not checked
- Not yet run on the box. A short black flash is possible as the picture moves to the full-screen surface; how visible it is depends on the box.
- Coming back from full screen to the menu or guide still re-tunes the picture.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 31
versionName = "0.30.0-drive-in"
versionCode = 32
versionName = "0.31.0-seamless-picture"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -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.30.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.31.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -89,6 +89,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
var channel by remember { mutableStateOf<SampleChannel?>(null) }
var watchingChannel by remember { mutableStateOf(false) }
var guideCategory by remember { mutableStateOf(ChannelCategory.All) }
val liveHandoff = remember { LiveHandoff() }
var channelClockOffset by remember { mutableLongStateOf(0) }
var busyLabel by remember { mutableStateOf("Connecting…") }
val sessionSlot = remember { EmbySessionSlot() }
@@ -235,12 +236,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)
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) }
val picture = @Composable { GuidePicture(liveSession, liveChannel, live = !watchingChannel, handoff = liveHandoff) }
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,
@@ -38,7 +38,7 @@ import kotlinx.coroutines.withContext
@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) {
onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }, subtitlePreference: MutableState<String?>? = null, runningPlayer: ExoPlayer? = null) {
val context = LocalContext.current
var failure by remember { mutableStateOf<String?>(null) }
var tuningNumberVisible by remember { mutableStateOf(true) }
@@ -53,13 +53,11 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
var subtitleIndex by remember { mutableStateOf<Int?>(null) }
var subtitleNote by remember { mutableStateOf<String?>(null) }
val player = remember(session, plan) {
if (runningPlayer != null) return@remember runningPlayer
val factory = DataSource.Factory { SecureMediaDataSource(session) }
ExoPlayer.Builder(context).setMediaSourceFactory(DefaultMediaSourceFactory(factory))
.setSeekBackIncrementMs(10_000).setSeekForwardIncrementMs(10_000).build().apply {
setMediaItem(MediaItem.Builder().setUri(plan.url).setSubtitleConfigurations(plan.subtitles.map { subtitle ->
MediaItem.SubtitleConfiguration.Builder(android.net.Uri.parse(subtitle.url)).setMimeType(subtitle.mime)
.setLanguage(subtitle.language).setLabel(subtitle.label).build()
}).build())
setMediaItem(channelMediaItem(plan))
if (scheduled != null) seekTo(scheduled.programme.offsetAt(scheduled.nowMs()))
prepare(); playWhenReady = true
}
@@ -104,7 +102,9 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
}
}
lifecycle.addObserver(observer)
var joinedSchedule = false
// 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
val listener = object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
val safeReason = generateSequence(error.cause) { it.cause }.filterIsInstance<MediaDeliveryException>().firstOrNull()?.message
@@ -24,12 +24,40 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
/** A stream already open in the picture slot, so going full screen can carry on with it instead of re-tuning. */
internal class LiveStream(val channelNumber: Int, val startMs: Long, val plan: EmbyPlaybackPlan, private val player: ExoPlayer) {
private var claimed = false
val available get() = !claimed
fun claim(): ExoPlayer? = if (claimed) null else player.also { claimed = true }
fun discard(session: EmbySampleSession) { claim()?.let { closeLiveStream(session, plan, it) } }
}
internal class LiveHandoff {
var open: LiveStream? = null
var handed: LiveStream? = null
/** Call before going full screen. The picture then leaves its stream running for the player to take. */
fun handOver(channelNumber: Int) { handed = open?.takeIf { it.channelNumber == channelNumber }; open = null }
fun take(): LiveStream? = handed.also { handed = null }
}
private fun closeLiveStream(session: EmbySampleSession, plan: EmbyPlaybackPlan, player: ExoPlayer) {
val position = player.currentPosition
player.release()
EmbyCleanup.run { session.report(plan, "Playing/Stopped", position, true) }
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
internal fun channelMediaItem(plan: EmbyPlaybackPlan): MediaItem = MediaItem.Builder().setUri(plan.url).setSubtitleConfigurations(plan.subtitles.map { subtitle ->
MediaItem.SubtitleConfiguration.Builder(android.net.Uri.parse(subtitle.url)).setMimeType(subtitle.mime)
.setLanguage(subtitle.language).setLabel(subtitle.label).build()
}).build()
/**
* The guide's picture slot: the tuned channel, live, on the channel's own clock. Artwork covers it until the
* 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) {
internal fun GuidePicture(session: EmbySampleSession, channel: SampleChannel, live: Boolean, handoff: LiveHandoff) {
val lifecycle = LocalLifecycleOwner.current.lifecycle
var foreground by remember { mutableStateOf(lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) }
DisposableEffect(lifecycle) {
@@ -46,7 +74,7 @@ internal fun GuidePicture(session: EmbySampleSession, channel: SampleChannel, li
var ready by remember(channel.number, current.startMs, live, foreground) { mutableStateOf(false) }
Box(Modifier.fillMaxSize().background(Panel)) {
if (live && foreground) key(channel.number, current.startMs) {
GuideLiveProgramme(session, current, onReady = { ready = true })
GuideLiveProgramme(session, channel.number, current, handoff, onReady = { ready = true })
}
val branding = ChannelBrands.forNumber(channel.number)
if (!ready && branding != null) Image(painterResource(branding.background), null, Modifier.matchParentSize(), contentScale = ContentScale.Crop)
@@ -54,7 +82,7 @@ internal fun GuidePicture(session: EmbySampleSession, channel: SampleChannel, li
}
@Composable
private fun GuideLiveProgramme(session: EmbySampleSession, programme: SampleBroadcast, onReady: () -> Unit) {
private fun GuideLiveProgramme(session: EmbySampleSession, channelNumber: Int, programme: SampleBroadcast, handoff: LiveHandoff, onReady: () -> Unit) {
var plan by remember { mutableStateOf<EmbyPlaybackPlan?>(null) }
LaunchedEffect(Unit) {
try {
@@ -63,23 +91,25 @@ private fun GuideLiveProgramme(session: EmbySampleSession, programme: SampleBroa
} catch (cancelled: CancellationException) { throw cancelled }
catch (_: Exception) { /* The artwork stays. Tuning full screen still explains any problem. */ }
}
plan?.let { GuideLivePlayer(session, it, programme, onReady) }
plan?.let { GuideLivePlayer(session, it, channelNumber, programme, handoff, onReady) }
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@Composable
private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan, programme: SampleBroadcast, onReady: () -> Unit) {
private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan, channelNumber: Int, programme: SampleBroadcast, handoff: LiveHandoff, onReady: () -> Unit) {
val context = LocalContext.current
var ready by remember { mutableStateOf(false) }
val player = remember(session, plan) {
val factory = DataSource.Factory { SecureMediaDataSource(session) }
ExoPlayer.Builder(context).setMediaSourceFactory(DefaultMediaSourceFactory(factory)).build().apply {
setMediaItem(MediaItem.fromUri(plan.url))
setMediaItem(channelMediaItem(plan))
seekTo(programme.offsetAt(System.currentTimeMillis()))
prepare(); playWhenReady = true
}
}
DisposableEffect(player) {
val stream = LiveStream(channelNumber, programme.startMs, plan, player)
handoff.open = stream
var joinedSchedule = false
val listener = object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
@@ -95,9 +125,10 @@ private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan,
}
player.addListener(listener)
onDispose {
val position = player.currentPosition
player.removeListener(listener); player.release()
EmbyCleanup.run { session.report(plan, "Playing/Stopped", position, true) }
player.removeListener(listener)
if (handoff.open === stream) handoff.open = null
// Handed to the full-screen player, which owns it from here.
if (handoff.handed !== stream && stream.available) stream.discard(session)
}
}
LaunchedEffect(player, ready) {
@@ -114,5 +145,5 @@ private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan,
// The grid keeps the remote; the picture never takes focus.
AndroidView(modifier = Modifier.fillMaxSize(), factory = { nativeContext ->
PlayerView(nativeContext).apply { useController = false; isFocusable = false; descendantFocusability = android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS; this.player = player }
})
}, onRelease = { it.player = null })
}
@@ -90,20 +90,27 @@ 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 }) {
onClose: () -> Unit, onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }, handoff: LiveHandoff? = 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) }
LaunchedEffect(channel, clockOffsetMs) { while (true) { now = System.currentTimeMillis() + clockOffsetMs; delay(250) } }
val shows = channel.guide(now, 2)
val current = shows[0]
val handed = remember {
val stream = handoff?.take()
if (stream != null && stream.channelNumber == channel.number && stream.startMs == current.startMs && clockOffsetMs == 0L) stream
else { stream?.discard(session); null }
}
val prepareMutex = remember(channel) { Mutex() }
var prepared by remember(channel) { mutableStateOf<EmbyPlaybackPlan?>(null) }
var preparedSlot by remember(channel) { mutableStateOf<Long?>(null) }
val handedNow = handed?.takeIf { it.available && it.channelNumber == channel.number }
var prepared by remember(channel) { mutableStateOf(handedNow?.plan) }
var preparedSlot by remember(channel) { mutableStateOf(handedNow?.startMs) }
var problem by remember(channel) { mutableStateOf<String?>(null) }
var retryAttempt by remember(channel) { mutableIntStateOf(0) }
// 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
prepared = null; preparedSlot = null; problem = null
try {
val plan = prepareMutex.withLock {
@@ -123,7 +130,7 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC
if (plan != null) {
key(current.startMs) {
EmbyPlaybackDialog(session, plan, onClose,
scheduled = ScheduledPlayback(channel, current, shows[1], clockOffsetMs), onStep = onStep, onLast = onLast, onNumber = onNumber, subtitlePreference = subtitlePreference)
scheduled = ScheduledPlayback(channel, current, shows[1], clockOffsetMs), onStep = onStep, onLast = onLast, onNumber = onNumber, subtitlePreference = subtitlePreference, runningPlayer = handed?.takeIf { it.plan === plan }?.claim())
}
} else Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false,
decorFitsSystemWindows = false, securePolicy = SecureFlagPolicy.SecureOn)) {
@@ -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.30.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.31.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()) {