Hand the picture's stream to full screen instead of re-tuning (0.31.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user