Play the tuned channel live in the guide's picture slot (0.27.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# Orbit TV 0.27.0 verification
|
||||
|
||||
Live picture-in-picture in the guide's picture slot.
|
||||
|
||||
## Changed
|
||||
- The guide's picture slot plays the tuned channel live, joined at the channel's scheduled position, with sound.
|
||||
- It stays on the tuned channel while the cursor browses other rows, so moving around never opens a new stream.
|
||||
- Channel artwork covers the slot until the first frame, and stays if the programme can't open.
|
||||
- The picture's stream stops before the full-screen player tunes, and when the app leaves the foreground, so only one stream is open at a time.
|
||||
- The runtime check is now one tested helper, used by both the guide picture and the full-screen player.
|
||||
|
||||
## Checked
|
||||
- assembleDebug, testDebugUnitTest and lintDebug pass; 129 tests, 0 failures.
|
||||
- APK signature verified with apksigner.
|
||||
|
||||
## Not checked
|
||||
- Not yet run on the box. The stream, sound, the change at a programme boundary and the return from full screen all need Paul's on-TV check.
|
||||
@@ -9,8 +9,8 @@ android {
|
||||
applicationId = "tv.orbit.classic"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 27
|
||||
versionName = "0.26.1-guide-panel"
|
||||
versionCode = 28
|
||||
versionName = "0.27.0-guide-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.26.1\"")
|
||||
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.27.0\"")
|
||||
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
|
||||
if (request.method == "POST") {
|
||||
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
|
||||
|
||||
@@ -230,7 +230,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
val previous = lastNumber
|
||||
if (previous != null && availableChannels.any { it.number == previous }) { lastNumber = selectedNumber; selectedNumber = previous; channelClockOffset = 0 }
|
||||
})
|
||||
GuideGrid(availableChannels, selectedNumber, modifier, onWatch = { number, offset ->
|
||||
GuideGrid(availableChannels, selectedNumber, modifier, picture = { GuidePicture(liveSession, it, live = !watchingChannel) }, onWatch = { number, offset ->
|
||||
if (number != selectedNumber) { lastNumber = selectedNumber; selectedNumber = number }
|
||||
channelClockOffset = offset; watchingChannel = true
|
||||
}, onBack = onBack)
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -63,7 +62,7 @@ internal fun guideStep(channel: SampleChannel, cursorMs: Long, nowMs: Long, forw
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modifier: Modifier = Modifier, onWatch: (Int, Long) -> Unit, onBack: () -> Unit) {
|
||||
internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modifier: Modifier = Modifier, picture: @Composable (SampleChannel) -> Unit = {}, onWatch: (Int, Long) -> Unit, onBack: () -> Unit) {
|
||||
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(Unit) { while (true) { delay(1000); now = System.currentTimeMillis() } }
|
||||
var row by remember { mutableIntStateOf(channels.indexOfFirst { it.number == selectedNumber }.coerceAtLeast(0)) }
|
||||
@@ -89,8 +88,9 @@ internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modif
|
||||
Column(modifier.fillMaxWidth().background(Navy).padding(horizontal = 14.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Row(Modifier.fillMaxWidth().height(96.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
val branding = ChannelBrands.forNumber(channel.number)
|
||||
// The picture stays on the tuned channel while the cursor browses, so moving rows never re-tunes.
|
||||
Box(Modifier.width(170.dp).fillMaxHeight().background(Panel)) {
|
||||
if (branding != null) Image(painterResource(branding.background), null, Modifier.matchParentSize(), contentScale = ContentScale.Crop)
|
||||
picture(channels.firstOrNull { it.number == selectedNumber } ?: channel)
|
||||
}
|
||||
// Four lines fill the panel's height. OK Watch is already in the legend below.
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.ui.PlayerView
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
var foreground by remember { mutableStateOf(lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) }
|
||||
DisposableEffect(lifecycle) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_START) foreground = true
|
||||
if (event == Lifecycle.Event.ON_STOP) foreground = false
|
||||
}
|
||||
lifecycle.addObserver(observer)
|
||||
onDispose { lifecycle.removeObserver(observer) }
|
||||
}
|
||||
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
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) }
|
||||
Box(Modifier.fillMaxSize().background(Panel)) {
|
||||
if (live && foreground) key(channel.number, current.startMs) {
|
||||
GuideLiveProgramme(session, current, onReady = { ready = true })
|
||||
}
|
||||
val branding = ChannelBrands.forNumber(channel.number)
|
||||
if (!ready && branding != null) Image(painterResource(branding.background), null, Modifier.matchParentSize(), contentScale = ContentScale.Crop)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GuideLiveProgramme(session: EmbySampleSession, programme: SampleBroadcast, onReady: () -> Unit) {
|
||||
var plan by remember { mutableStateOf<EmbyPlaybackPlan?>(null) }
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
val opened = withContext(Dispatchers.IO) { session.prepareForTuning(programme.media.item, programme.media.sourceId) { Thread.sleep(2_000) } }
|
||||
if (scheduledRuntimeMatches(opened.durationMs, programme.media.durationMs)) plan = opened
|
||||
} catch (cancelled: CancellationException) { throw cancelled }
|
||||
catch (_: Exception) { /* The artwork stays. Tuning full screen still explains any problem. */ }
|
||||
}
|
||||
plan?.let { GuideLivePlayer(session, it, programme, onReady) }
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
@Composable
|
||||
private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan, programme: SampleBroadcast, 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))
|
||||
seekTo(programme.offsetAt(System.currentTimeMillis()))
|
||||
prepare(); playWhenReady = true
|
||||
}
|
||||
}
|
||||
DisposableEffect(player) {
|
||||
var joinedSchedule = false
|
||||
val listener = object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(state: Int) {
|
||||
if (state == Player.STATE_READY) {
|
||||
if (!joinedSchedule) {
|
||||
joinedSchedule = true
|
||||
val target = programme.offsetAt(System.currentTimeMillis())
|
||||
if (kotlin.math.abs(player.currentPosition - target) > 2_000) player.seekTo(target)
|
||||
}
|
||||
ready = true; onReady()
|
||||
}
|
||||
}
|
||||
}
|
||||
player.addListener(listener)
|
||||
onDispose {
|
||||
val position = player.currentPosition
|
||||
player.removeListener(listener); player.release()
|
||||
EmbyCleanup.run { session.report(plan, "Playing/Stopped", position, true) }
|
||||
}
|
||||
}
|
||||
LaunchedEffect(player, ready) {
|
||||
if (ready) {
|
||||
val startPosition = player.currentPosition
|
||||
withContext(Dispatchers.IO) { session.report(plan, "Playing", startPosition, false) }
|
||||
while (true) {
|
||||
delay(10_000)
|
||||
val position = player.currentPosition; val paused = !player.isPlaying
|
||||
withContext(Dispatchers.IO) { session.report(plan, "Playing/Progress", position, paused) }
|
||||
}
|
||||
}
|
||||
}
|
||||
// The grid keeps the remote; the picture never takes focus.
|
||||
AndroidView(modifier = Modifier.fillMaxSize(), factory = { nativeContext ->
|
||||
PlayerView(nativeContext).apply { useController = false; isFocusable = false; this.player = player }
|
||||
})
|
||||
}
|
||||
@@ -85,6 +85,9 @@ internal fun SampleChannelGuide(channel: SampleChannel, modifier: Modifier = Mod
|
||||
}
|
||||
}
|
||||
|
||||
/** A schedule is only true if the file still runs as long as the slot it was given. */
|
||||
internal fun scheduledRuntimeMatches(openedMs: Long?, scheduledMs: Long) = openedMs != null && kotlin.math.abs(openedMs - scheduledMs) <= 2_000
|
||||
|
||||
@Composable
|
||||
internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleChannel, clockOffsetMs: Long,
|
||||
onClose: () -> Unit, onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}) {
|
||||
@@ -106,7 +109,7 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC
|
||||
session.prepareForTuning(current.media.item, current.media.sourceId) { Thread.sleep(2_000) }
|
||||
}
|
||||
}
|
||||
if (plan.durationMs == null || kotlin.math.abs(plan.durationMs - current.media.durationMs) > 2_000)
|
||||
if (!scheduledRuntimeMatches(plan.durationMs, current.media.durationMs))
|
||||
throw EmbyCheckException("This programme's runtime changed. Return and rebuild the sample channel.")
|
||||
preparedSlot = current.startMs; prepared = plan
|
||||
} catch (cancelled: CancellationException) { throw cancelled }
|
||||
|
||||
@@ -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.26.1")
|
||||
next.setRequestProperty("User-Agent", "OrbitTV/0.27.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()) {
|
||||
|
||||
@@ -52,4 +52,11 @@ class GuideGridTest {
|
||||
val later = current.endMs + minute
|
||||
assertEquals(current.endMs - 1, guideStep(channel, later, now, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aProgrammeOnlyOpensIfItStillFitsItsSlot() {
|
||||
assertTrue(scheduledRuntimeMatches(1_000_000, 1_001_500))
|
||||
assertFalse(scheduledRuntimeMatches(1_000_000, 1_002_001))
|
||||
assertFalse(scheduledRuntimeMatches(null, 1_000_000))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user