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 @@ -48,7 +48,7 @@ class AndroidDeviceMetadataProvider(

private fun clientNameFor(platform: String): String =
when (platform) {
"android-tv" -> "Prairie Android TV"
"androidtv", "android-tv" -> "Prairie Android TV"
"android" -> "Prairie Android"
else -> "Prairie Android"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2655,6 +2655,170 @@ open class PlaybackSessionManager(
/** Returns the server base URL for resolving relative stream URLs. */
suspend fun getServerUrl(): String = tokenManager.getServerUrl()

enum class TranscodeMode { REMUX, FULL }

/**
* Issue a `TranscodeStartRequest` for a fallback path — either because the
* server chose REMUX / TRANSCODE up front (`handleSessionStarted`) or
* because client-side preflight determined direct play was impossible
* ([PlaybackPreflightListener] in PR 8). Folds the resulting HLS URL back
* into a [PlaybackSessionResponse] so both VMs can treat the result like
* any other session start.
*
* Does **not** stop the caller's current session — ViewModels handle that
* alongside their state cleanup, which is the point they also tear down
* progress reporting.
*/
suspend fun startTranscodeFallback(
session: PlaybackSessionResponse,
seekSeconds: Double,
resolution: String,
mode: TranscodeMode,
audioTrackIndex: Int? = null,
subtitleTrackIndex: Int? = null,
targetBitrateKbps: Int? = null,
copyVideo: Boolean = false,
): ApiResult<PlaybackSessionResponse> {
val isRemux = mode == TranscodeMode.REMUX || copyVideo
val bitrate = when {
isRemux -> 0
targetBitrateKbps != null && targetBitrateKbps > 0 -> targetBitrateKbps
else -> 8000
}
val request = TranscodeStartRequest(
sessionId = session.sessionId,
seekSeconds = seekSeconds,
targetResolution = if (isRemux) "" else resolution,
targetCodecVideo = if (isRemux) "copy" else "h264",
// REMUX copies audio to preserve passthrough codecs
// (EAC3/TrueHD/DTS). Forcing AAC clobbers the play-method
// decision.
targetCodecAudio = if (isRemux) "copy" else "aac",
targetBitrateKbps = bitrate,
segmentDuration = 2,
audioTrackIndex = audioTrackIndex,
subtitleTrackIndex = subtitleTrackIndex,
subtitleBurnIn = shouldBurnStyledSubtitle(
isRemux = isRemux,
subtitleTrackIndex = subtitleTrackIndex,
subtitleCodec = session.playbackPlan?.source?.subtitleCodec,
),
)
Log.i(
TAG,
"startTranscodeFallback session=${session.sessionId} mode=$mode seekSeconds=$seekSeconds " +
"targetResolution=${request.targetResolution} " +
"targetCodecVideo=${request.targetCodecVideo} " +
"targetCodecAudio=${request.targetCodecAudio} " +
"targetBitrateKbps=${request.targetBitrateKbps} " +
"audioTrackIndex=$audioTrackIndex subtitleTrackIndex=$subtitleTrackIndex",
)
return when (val r = playbackRepository.startTranscode(request)) {
is ApiResult.Success -> {
val tc = r.data
ApiResult.Success(
session.copy(
sessionId = tc.sessionId,
playMethod = if (isRemux) {
org.prairieserver.prairie.model.playback.PlayMethod.REMUX
} else {
org.prairieserver.prairie.model.playback.PlayMethod.TRANSCODE
},
streamUrl = tc.manifestUrl,
durationSeconds = tc.durationSeconds ?: session.durationSeconds,
position = tc.playerStartSeconds,
playbackPlan = session.playbackPlan?.let { plan ->
plan.copy(
delivery = if (isRemux) {
PlaybackDelivery.SERVER_REMUX_HLS
} else {
PlaybackDelivery.SERVER_TRANSCODE_HLS
},
engine = PlaybackEngineKind.MEDIA3_HLS,
routeFamily = PlaybackRouteFamily.SERVER_ADAPTIVE,
stream = PlaybackStreamRequest(
url = tc.manifestUrl,
streamType = "hls",
playMethod = if (isRemux) {
org.prairieserver.prairie.model.playback.PlayMethod.REMUX
} else {
org.prairieserver.prairie.model.playback.PlayMethod.TRANSCODE
},
),
timeline = PlaybackTimeline(
playerStartSeconds = tc.playerStartSeconds,
streamOriginSeconds = tc.streamOriginSeconds,
timelineOffsetSeconds = tc.timelineOffsetSeconds,
canSeekAnywhere = tc.canSeekAnywhere,
),
degradationWarnings = plan.degradationWarnings +
org.prairieserver.prairie.model.playback.PlaybackDegradationWarning(
code = if (isRemux) {
"server_remux_fallback"
} else {
"server_transcode_fallback"
},
message = if (isRemux) {
"Playback fell back to server remux."
} else {
"Playback fell back to server transcode."
},
),
)
},
),
)
}
is ApiResult.Error -> r
is ApiResult.NetworkError -> r
}
}

suspend fun startTranscodeFallbackRecoveringMissingSession(
session: PlaybackSessionResponse,
seekSeconds: Double,
resolution: String,
mode: TranscodeMode,
audioTrackIndex: Int? = null,
subtitleTrackIndex: Int? = null,
targetBitrateKbps: Int? = null,
copyVideo: Boolean = false,
renewSession: suspend () -> ApiResult<PlaybackSessionResponse>,
): ApiResult<PlaybackSessionResponse> {
val first = startTranscodeFallback(
session = session,
seekSeconds = seekSeconds,
resolution = resolution,
mode = mode,
audioTrackIndex = audioTrackIndex,
subtitleTrackIndex = subtitleTrackIndex,
targetBitrateKbps = targetBitrateKbps,
copyVideo = copyVideo,
)
if (!first.isPlaybackSessionMissingError()) return first

Log.w(TAG, "Fallback session missing; renewing playback session before retry")
return when (val renewed = renewSession()) {
is ApiResult.Success -> {
val retry = startTranscodeFallback(
session = renewed.data,
seekSeconds = seekSeconds,
resolution = resolution,
mode = mode,
audioTrackIndex = audioTrackIndex,
subtitleTrackIndex = subtitleTrackIndex,
targetBitrateKbps = targetBitrateKbps,
copyVideo = copyVideo,
)
if (retry !is ApiResult.Success) {
stopSession(renewed.data.sessionId)
}
retry
}
is ApiResult.Error -> renewed
is ApiResult.NetworkError -> renewed
}
}
}

internal fun ApiResult<*>.isPlaybackSessionMissingError(): Boolean {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package org.prairieserver.prairie.common.player

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import org.prairieserver.prairie.playback.TrickplayTilePreview

/**
* Crops one tile out of a trickplay sprite sheet using the same layout math as
* web SeekBar (`backgroundSize` / `backgroundPosition` percentage sprites).
*/
@Composable
fun TrickplayTileImage(
tile: TrickplayTilePreview,
previewWidth: Dp = 176.dp,
modifier: Modifier = Modifier,
) {
val aspect = tile.width.toFloat() / tile.height.toFloat().coerceAtLeast(1f)
val previewHeight = previewWidth / aspect
val density = LocalDensity.current
val sheetWidth = previewWidth * tile.columns
val sheetHeight = previewHeight * tile.rows
val offsetX = with(density) { (-previewWidth * tile.col).toPx() }
val offsetY = with(density) { (-previewHeight * tile.row).toPx() }

Box(
modifier = modifier
.width(previewWidth)
.height(previewHeight)
.clip(RoundedCornerShape(4.dp))
.background(Color.Black),
) {
AsyncImage(
model = tile.url,
contentDescription = null,
contentScale = ContentScale.FillBounds,
modifier = Modifier
.size(sheetWidth, sheetHeight)
.offset { androidx.compose.ui.unit.IntOffset(offsetX.toInt(), offsetY.toInt()) },
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ val androidModule = module {
finalPlaybackPositionWriter = get(),
sectionRepository = get(),
castPlaybackPreparer = get(),
qualityLadderClient = get(),
)
}
viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull(), get()) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ fun PlaybackStatsSheet(
// Gear-submenu back affordance: dismisses this sheet and reopens the
// parent settings sheet (wired in PlayerOverlay).
onBack: (() -> Unit)? = null,
sessionId: String? = null,
playMethod: String? = null,
positionLabel: String? = null,
tabletopPaneHeight: Dp? = null,
) {
if (!isVisible) return
Expand Down Expand Up @@ -77,7 +80,7 @@ fun PlaybackStatsSheet(
.padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 24.dp),
) {
PlayerSheetHeader(
title = "Playback Stats",
title = "Stats for nerds",
onBack = onBack?.let { back ->
{ scope.dismissPlayerSheet(sheetState, back) }
},
Expand All @@ -91,7 +94,12 @@ fun PlaybackStatsSheet(
)
Spacer(modifier = Modifier.height(18.dp))

val rows = stats.mobileStatsRows()
val rows = buildList {
sessionId?.takeIf { it.isNotBlank() }?.let { add("Session" to it) }
playMethod?.takeIf { it.isNotBlank() }?.let { add("Play method" to it) }
positionLabel?.takeIf { it.isNotBlank() }?.let { add("Position" to it) }
addAll(stats.mobileStatsRows())
}
if (rows.isEmpty()) {
Text(
text = "Waiting for player data",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ fun PlayerControls(
hasMultipleVersions: Boolean,
chapters: List<org.prairieserver.prairie.model.catalog.VersionChapter> = emptyList(),
intro: org.prairieserver.prairie.model.catalog.TimeRange? = null,
trickplay: org.prairieserver.prairie.playback.TrickplayInfo? = null,
credits: org.prairieserver.prairie.model.catalog.TimeRange? = null,
recap: org.prairieserver.prairie.model.catalog.TimeRange? = null,
preview: org.prairieserver.prairie.model.catalog.TimeRange? = null,
Expand Down Expand Up @@ -214,6 +215,7 @@ fun PlayerControls(
enabled = seekEnabled,
chapters = chapters,
intro = intro,
trickplay = trickplay,
credits = credits,
recap = recap,
preview = preview,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import org.prairieserver.prairie.android.ui.util.LanguageNames
import org.prairieserver.prairie.android.ui.util.formatClockTime
import org.prairieserver.prairie.common.player.SessionState
import org.prairieserver.prairie.common.player.SleepTimerState
import org.prairieserver.prairie.model.watchtogether.MemberRole
Expand Down Expand Up @@ -79,6 +80,7 @@ fun PlayerOverlay(
onSelectSubtitle: (Int) -> Unit,
onSelectAudio: (Int) -> Unit,
onSelectVersion: (Int) -> Unit,
onSelectQuality: (String) -> Unit = {},
// Google Cast (Chromecast) button rendered in the transport top bar.
castSlot: @Composable () -> Unit = {},
modifier: Modifier = Modifier,
Expand Down Expand Up @@ -349,12 +351,13 @@ fun PlayerOverlay(
bufferedPosition = state.bufferedPosition,
chapters = state.chapters,
intro = state.intro,
trickplay = state.trickplay,
credits = state.credits,
recap = state.recap,
preview = state.preview,
hasChapters = state.chapters.isNotEmpty(),
hasTracks = state.subtitleTracks.isNotEmpty() || state.audioTracks.isNotEmpty(),
hasMultipleVersions = state.versions.size > 1,
hasMultipleVersions = state.versions.size > 1 || state.qualityOptions.size > 1,
isOrientationLocked = isOrientationLocked,
orientationLockSupported = orientationLockSupported,
tabletopMode = tabletopMode,
Expand Down Expand Up @@ -580,6 +583,9 @@ fun PlayerOverlay(
selectedIndex = state.selectedVersionIndex,
onSelect = onSelectVersion,
onDismiss = { showQualitySelector = false },
qualityOptions = state.qualityOptions,
selectedQualityId = state.selectedQualityId,
onSelectQuality = onSelectQuality,
tabletopPaneHeight = tabletopPaneHeight,
)
}
Expand Down Expand Up @@ -632,6 +638,9 @@ fun PlayerOverlay(
statsSheetVisible = false
settingsSheetVisible = true
},
sessionId = state.sessionId,
playMethod = state.playMethod?.name,
positionLabel = formatClockTime(state.position) + " / " + formatClockTime(state.duration),
tabletopPaneHeight = tabletopPaneHeight,
)

Expand Down
Loading
Loading