Picture hands over to full screen; shorter squeeze-back for shows

Port of release 0.108.64.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-06 17:10:55 +00:00
co-authored by Claude Opus 5.5
parent b0aa9fa82f
commit 28cc54e8d1
4 changed files with 29 additions and 6 deletions
@@ -29,6 +29,12 @@ import kotlinx.coroutines.withContext
internal class LiveStream(val channelNumber: Int, val startMs: Long, val plan: EmbyPlaybackPlan, private val player: ExoPlayer) {
private var claimed = false
val available get() = !claimed
/**
* Handed to full screen. The picture then leaves it running even though full screen, which opens in its own window,
* only claims it a moment later (Paul, 6 November 2026: choosing full screen still re-tuned, as the picture let go of
* the stream before full screen had taken it).
*/
var handedOff = false
fun claim(): ExoPlayer? = if (claimed) null else player.also { claimed = true }
fun discard(session: EmbySampleSession) { claim()?.let { closeLiveStream(session, plan, it) } }
}
@@ -39,7 +45,7 @@ internal class LiveHandoff {
/** Call before going full screen. The picture then leaves its stream running for the player to take. */
fun handOver(channelNumber: Int) {
android.util.Log.i("OrbitSchedule", "picture handover channel=$channelNumber pictureOpen=${open != null} sameChannel=${open?.channelNumber == channelNumber}")
handed = open?.takeIf { it.channelNumber == channelNumber }; open = null
handed = open?.takeIf { it.channelNumber == channelNumber }?.also { it.handedOff = true }; open = null
}
fun take(): LiveStream? = handed.also { handed = null }
/** Left by the full-screen player on the way out, for the picture to carry on with. */
@@ -164,7 +170,7 @@ private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan,
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)
if (!stream.handedOff && stream.available) stream.discard(session)
}
}
LaunchedEffect(player, ready) {
@@ -56,6 +56,8 @@ 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 }
}
// A handed stream full screen didn't end up using is closed with it, so it never plays on unseen.
DisposableEffect(Unit) { onDispose { handed?.discard(session) } }
val next = shows[1]
val identsOn = remember { OrbitSettings(settingsContext).idents }
val specialOn = remember { OrbitSettings(settingsContext).specialIdents }
@@ -66,7 +68,7 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC
// The squeeze-back (Paul, 6 November 2026): over the last minute, with the next programme loading from its start.
val squeezeOn = remember { OrbitSettings(settingsContext).squeezeBack }
val calmMotion = remember { OrbitSettings(settingsContext).reduceMotion }
val squeezing = squeezeOn && channel is ScheduledChannel && squeezeBackDue(channel.number, current.startMs, current.endMs, next.startMs, now, if (identsHere) identMs else 0L, clockOffsetMs)
val squeezing = squeezeOn && channel is ScheduledChannel && squeezeBackDue(channel.number, current.startMs, current.endMs, next.startMs, now, if (identsHere) identMs else 0L, clockOffsetMs, squeezeLength(current.media.item.type))
val squeeze by androidx.compose.animation.core.animateFloatAsState(if (squeezing) 1f else 0f,
if (calmMotion) androidx.compose.animation.core.snap() else androidx.compose.animation.core.tween(1_000), label = "squeeze")
val phase = if (identsHere) identPhase(current.startMs, current.endMs, now, identMs, if (squeezeOn) SQUEEZE_MS + IDENT_LOAD_AHEAD_MS else IDENT_LOAD_AHEAD_MS) else IdentPhase.None
@@ -31,12 +31,19 @@ internal const val SQUEEZE_MS = 60_000L
internal const val SQUEEZE_SHRINK = .46f
private const val SQUEEZE_SHORTEST_MS = 10 * 60_000L
/** Whether the squeeze-back is showing now: the minute before the ident (or before the end, with no ident). */
internal fun squeezeBackDue(number: Int, startMs: Long, endMs: Long, nextStartMs: Long, nowMs: Long, identMs: Long, offsetMs: Long): Boolean {
/**
* How long the squeeze-back lasts (Paul, 6 November 2026): a TV show's end goes small for its last 22 seconds, a film's for
* its last minute, as films have longer credits.
*/
internal const val SQUEEZE_SHOW_MS = 22_000L
internal fun squeezeLength(type: String) = if (type == "Movie") SQUEEZE_MS else SQUEEZE_SHOW_MS
/** Whether the squeeze-back is showing now: the [lengthMs] before the ident (or before the end, with no ident). */
internal fun squeezeBackDue(number: Int, startMs: Long, endMs: Long, nextStartMs: Long, nowMs: Long, identMs: Long, offsetMs: Long, lengthMs: Long = SQUEEZE_MS): Boolean {
if (offsetMs != 0L || number == DRIVE_IN_NUMBER || endMs - startMs < SQUEEZE_SHORTEST_MS || nextStartMs != endMs) return false
if (number == TOTS_NUMBER && totsMood(nowMs).evening) return false
val until = endMs - identMs
return nowMs >= until - SQUEEZE_MS && nowMs < until
return nowMs >= until - lengthMs && nowMs < until
}
/** Each channel's cast member in the squeeze-back: Ollie on Tots, the others shared round the rest. */
@@ -45,4 +45,12 @@ class SqueezeBackTest {
assertNull(squeezeCast(TOTS_NUMBER)); assertNotNull(squeezeCast(106))
assertEquals(squeezeCast(106), squeezeCast(106))
}
@Test fun aShowGoesSmallForItsLast22SecondsAFilmForItsLastMinute() {
assertEquals(SQUEEZE_SHOW_MS, squeezeLength("Episode")); assertEquals(SQUEEZE_MS, squeezeLength("Movie"))
val start = 0L; val end = 30 * 60_000L
assertFalse(squeezeBackDue(106, start, end, end, end - 30_000, 0, 0, squeezeLength("Episode")))
assertTrue(squeezeBackDue(106, start, end, end, end - 20_000, 0, 0, squeezeLength("Episode")))
assertTrue(squeezeBackDue(106, start, end, end, end - 50_000, 0, 0, squeezeLength("Movie")))
}
}