Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ internal enum class DolbyVisionTransformMode {

internal data class SiloMediaTransformTag(
val dolbyVisionMode: DolbyVisionTransformMode,
val mountToken: Long? = null,
val expectedDynamicRange: String? = null,
val expectedColorRange: String? = null,
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@ class SiloPlayerFactory(
*/
fun buildMediaItem(
contentId: String? = null,
mountToken: Long? = null,
streamUrl: String,
playMethod: PlayMethod,
delivery: PlaybackDelivery? = null,
Expand Down Expand Up @@ -514,6 +515,7 @@ class SiloPlayerFactory(
DolbyVisionTransformMode.PROFILE7_TO_HDR10
else -> DolbyVisionTransformMode.DISABLED
},
mountToken = mountToken,
expectedDynamicRange = expectedDynamicRange,
expectedColorRange = expectedColorRange,
dolbyVisionBaseLayerRoute =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ fun mountVideoMedia(
) {
val mediaItem = playerFactory.buildMediaItem(
contentId = spec.contentId,
mountToken = spec.mountToken,
streamUrl = spec.streamUrl,
playMethod = spec.playMethod,
delivery = spec.delivery,
Expand Down Expand Up @@ -47,6 +48,7 @@ fun refreshMountedVideoMedia(
val wasPlaying = player.playWhenReady
val mediaItem = playerFactory.buildMediaItem(
contentId = spec.contentId,
mountToken = spec.mountToken,
streamUrl = spec.streamUrl,
playMethod = spec.playMethod,
delivery = spec.delivery,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package org.siloserver.silo.common.player

import androidx.media3.common.Timeline
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.analytics.AnalyticsListener
import org.siloserver.silo.model.playback.PlayMethod
import org.siloserver.silo.model.playback.PlaybackDelivery
import org.siloserver.silo.model.playback.PlaybackExecutionPlan
Expand Down Expand Up @@ -123,6 +126,8 @@ data class VideoPlayerMediaSpec(
* media-session identity and playback diagnostics.
*/
val contentId: String? = null,
/** Immutable identity used to correlate asynchronous Media3 callbacks with this mount. */
val mountToken: Long? = null,
val streamUrl: String,
val playMethod: PlayMethod,
val delivery: PlaybackDelivery? = null,
Expand Down Expand Up @@ -160,3 +165,11 @@ data class VideoPlayerMediaSpec(
return (seconds * 1000.0).toLong().coerceAtLeast(1L)
}
}

/** Returns the mount identity carried by the media item that produced this event. */
@UnstableApi
fun AnalyticsListener.EventTime.videoMountToken(): Long? {
if (windowIndex < 0 || windowIndex >= timeline.windowCount) return null
val mediaItem = timeline.getWindow(windowIndex, Timeline.Window()).mediaItem
return (mediaItem.localConfiguration?.tag as? SiloMediaTransformTag)?.mountToken
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package org.siloserver.silo.common.player

import android.graphics.Rect
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.unit.Constraints
import kotlin.math.roundToInt

/** Resize the mounted PlayerView without replacing its surface or player. */
fun Modifier.videoPlayerViewport(viewport: Rect?, parentBounds: Rect?): Modifier = layout { measurable, constraints ->
if (viewport == null || parentBounds == null) {
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) { placeable.place(0, 0) }
} else {
val placeable = measurable.measure(
Constraints.fixed(viewport.width().coerceAtLeast(0), viewport.height().coerceAtLeast(0)),
)
layout(constraints.maxWidth, constraints.maxHeight) {
placeable.place(viewport.left - parentBounds.left, viewport.top - parentBounds.top)
}
}
}

fun LayoutCoordinates.videoViewportBounds(): Rect = positionInWindow().let { position ->
Rect(
position.x.roundToInt(),
position.y.roundToInt(),
(position.x + size.width).roundToInt(),
(position.y + size.height).roundToInt(),
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package org.siloserver.silo.common.player.video

/**
* Owns one in-place Next Up handoff until the successor's exact media mount
* renders a frame. A callback from the outgoing item, or from a superseded
* replacement mount, can never complete the transition.
*/
class NextUpTransitionGate {
private data class Transition(
val contentId: String,
val expectedMountToken: Long? = null,
)

private var transition: Transition? = null

val isActive: Boolean
get() = synchronized(this) { transition != null }

@Synchronized
fun begin(contentId: String): Boolean {
if (contentId.isBlank() || transition != null) return false
transition = Transition(contentId = contentId)
return true
}

@Synchronized
fun expectMount(contentId: String, mountToken: Long): Boolean {
val current = transition ?: return false
if (current.contentId != contentId) return false
transition = current.copy(expectedMountToken = mountToken)
return true
}

@Synchronized
fun completeOnFirstFrame(mountToken: Long?): Boolean {
val current = transition ?: return false
if (mountToken == null || current.expectedMountToken != mountToken) return false
transition = null
return true
}

@Synchronized
fun cancel() {
transition = null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,10 @@ class PlaybackStartupStallDetector(
// renderer is enabled, so a baseline captured at mount can be compared
// against a counter that restarted at zero, and a healthy stream then
// looks frozen until it has rendered as many frames again. That trades
// a rare missed freeze for a common invented one. The residual — a
// reused player whose cumulative count makes the first sample look like
// this attempt already rendered — is accepted, and the real fix is
// AnalyticsListener.onRenderedFirstFrame(EventTime) carried through a
// mount key, which needs hardware to validate.
// a rare missed freeze for a common invented one. The caller therefore
// correlates AnalyticsListener.onRenderedFirstFrame(EventTime) through
// the immutable mount key on the event's MediaItem. The counter remains
// only a fallback signal for devices that omit that analytics callback.
this.decoderStartupAtMs = null
this.clientDolbyVisionTransform = clientTransformations.any {
it == CLIENT_DV7_TO_DV81 || it == CLIENT_DV7_TO_HDR10
Expand All @@ -81,26 +80,7 @@ class PlaybackStartupStallDetector(
this.lastProgressAtMs = nowMs
}

/**
* A frame rendered. Which stream rendered it is NOT known.
*
* Media3's callback carries no identity, so one from an outgoing stream can
* vouch for its replacement. That is a real defect and it is deliberately
* left in place: the two cheaper alternatives are both worse.
*
* Qualifying the callback with a key rebuilt from live state fails, because
* that key describes when the event was DELIVERED, not what rendered it.
* Comparing decoder counters against a mount baseline fails too, because
* Media3 creates fresh DecoderCounters when a renderer is enabled — so an
* outgoing count compared against a restarted counter would make a healthy
* stream look frozen until it had rendered as many frames again, which
* trades a rare missed freeze for a common false one.
*
* The correct fix is AnalyticsListener.onRenderedFirstFrame(EventTime),
* whose EventTime identifies the media period, carried through a mount key
* on the MediaItem tag. That is Media3 integration work whose failure modes
* are device-specific, and it is not being written blind.
*/
/** Marks a frame after the caller verifies the analytics event's immutable mount key. */
fun onFirstFrameRendered() {
firstFrameRendered = true
decoderStartupAtMs = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,7 @@ class PostResumeVideoStallDetector(
baselineRenderedCount = 0
}

/**
* A frame rendered. Provenance unknown — see PlaybackStartupStallDetector
* for why neither a rebuilt key nor a counter baseline can supply it, and
* what the real fix is.
*/
/** Marks a frame after the caller verifies the analytics event's immutable mount key. */
fun onFirstFrameRendered() {
firstFrameRendered = true
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class VideoPlaybackSessionCoordinator(
preview = result.preview,
chapters = result.chapters,
seriesId = result.seriesId,
seriesTitle = result.seriesTitle,
seasonNumber = result.seasonNumber,
episodeNumber = result.episodeNumber,
resolvedEpisodeSelection = result.resolvedEpisodeSelection,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ sealed interface VideoPlaybackStartResult {
val episodeNumber: Int? = null,
/** TV's target-catalog resolution of [VideoPlaybackStartRequest.episodeSelectionHandoff]. */
val resolvedEpisodeSelection: ResolvedEpisodeSelection? = null,
val seriesTitle: String? = null,
) : VideoPlaybackStartResult

data class Error(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ sealed interface VideoPlayerUiState {
val episodeNumber: Int? = null,
/** Target-catalog decision for the one-shot episode-selection handoff. */
val resolvedEpisodeSelection: ResolvedEpisodeSelection? = null,
val seriesTitle: String? = null,
) : VideoPlayerUiState {
override val hasPlayableMedia: Boolean = true

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package org.siloserver.silo.common.player.video

import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class NextUpTransitionGateTest {
@Test
fun `only successor first frame completes transition`() {
val gate = NextUpTransitionGate()

assertTrue(gate.begin("episode-b"))
assertFalse(gate.completeOnFirstFrame(1L))
assertFalse(gate.expectMount("episode-a", 2L))
assertTrue(gate.expectMount("episode-b", 2L))
assertFalse(gate.completeOnFirstFrame(1L))
assertTrue(gate.isActive)
assertTrue(gate.completeOnFirstFrame(2L))
assertFalse(gate.isActive)
}

@Test
fun `queued predecessor frame cannot complete successor recovery mount`() {
val gate = NextUpTransitionGate()

assertTrue(gate.begin("episode-b"))
assertTrue(gate.expectMount("episode-b", 10L))
assertTrue(gate.expectMount("episode-b", 11L))
assertFalse(gate.completeOnFirstFrame(10L))
assertTrue(gate.isActive)
assertTrue(gate.completeOnFirstFrame(11L))
assertFalse(gate.isActive)
}

@Test
fun `duplicate actions are rejected until completion or cancel`() {
val gate = NextUpTransitionGate()

assertTrue(gate.begin("episode-b"))
assertFalse(gate.begin("episode-b"))
assertFalse(gate.begin("episode-c"))
gate.cancel()
assertTrue(gate.begin("episode-c"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@ internal class MobileVideoPlaybackStarter(
preview = watchDetail.preview,
chapters = effectiveVersion?.chapters.orEmpty(),
seriesId = watchDetail.seriesId,
seriesTitle = watchDetail.seriesTitle,
seasonNumber = watchDetail.seasonNumber,
episodeNumber = watchDetail.episodeNumber,
)
Expand Down
Loading
Loading