diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/network/AndroidDeviceMetadataProvider.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/network/AndroidDeviceMetadataProvider.kt index c0e46fbdf..318069c36 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/network/AndroidDeviceMetadataProvider.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/network/AndroidDeviceMetadataProvider.kt @@ -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" } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManager.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManager.kt index cd922fcfc..5e0a6f73c 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManager.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManager.kt @@ -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 { + 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, + ): ApiResult { + 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 { diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/TrickplayTileImage.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/TrickplayTileImage.kt new file mode 100644 index 000000000..32b66562a --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/TrickplayTileImage.kt @@ -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()) }, + ) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt index 13f3e28f2..ccfa4bcb5 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt @@ -363,6 +363,7 @@ val androidModule = module { finalPlaybackPositionWriter = get(), sectionRepository = get(), castPlaybackPreparer = get(), + qualityLadderClient = get(), ) } viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull(), get()) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackStatsSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackStatsSheet.kt index 72860e55c..f6a19df9c 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackStatsSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackStatsSheet.kt @@ -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 @@ -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) } }, @@ -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", diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerControls.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerControls.kt index 78fb89cb3..b514648c2 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerControls.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerControls.kt @@ -103,6 +103,7 @@ fun PlayerControls( hasMultipleVersions: Boolean, chapters: List = 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, @@ -214,6 +215,7 @@ fun PlayerControls( enabled = seekEnabled, chapters = chapters, intro = intro, + trickplay = trickplay, credits = credits, recap = recap, preview = preview, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOverlay.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOverlay.kt index d1d2b5fd0..f5785de93 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOverlay.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOverlay.kt @@ -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 @@ -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, @@ -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, @@ -580,6 +583,9 @@ fun PlayerOverlay( selectedIndex = state.selectedVersionIndex, onSelect = onSelectVersion, onDismiss = { showQualitySelector = false }, + qualityOptions = state.qualityOptions, + selectedQualityId = state.selectedQualityId, + onSelectQuality = onSelectQuality, tabletopPaneHeight = tabletopPaneHeight, ) } @@ -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, ) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerProgressBar.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerProgressBar.kt index 0969f7c6c..2c90135ab 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerProgressBar.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerProgressBar.kt @@ -40,20 +40,22 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.prairieserver.prairie.android.ui.util.formatClockTime +import org.prairieserver.prairie.common.player.TrickplayTileImage import org.prairieserver.prairie.model.catalog.TimeRange import org.prairieserver.prairie.model.catalog.VersionChapter +import org.prairieserver.prairie.playback.TrickplayInfo +import org.prairieserver.prairie.playback.resolveTrickplayTile /** * Seek bar with current/total time and a buffered-ahead track, mirroring - * iOS `MobilePlayerControls.progressSlider`: + * iOS `MobilePlayerControls.progressSlider` plus web trickplay scrub previews: * * - three track regions — played, buffered (safe to seek into), base; * - detected marker ranges tinted as bands (intro cyan, recap green, * credits orange, preview purple); * - a 2dp chapter tick per chapter, drawn under the played fill; - * - while scrubbing, a preview bubble above the thumb with the target time - * and the chapter title at that point (text only — iOS has no thumbnail - * trickplay either). + * - while scrubbing, a preview bubble above the thumb with trickplay tiles + * when available, the target time, and the chapter title at that point. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -66,6 +68,7 @@ fun PlayerProgressBar( enabled: Boolean = true, chapters: List = emptyList(), intro: TimeRange? = null, + trickplay: TrickplayInfo? = null, credits: TimeRange? = null, recap: TimeRange? = null, preview: TimeRange? = null, @@ -90,14 +93,16 @@ fun PlayerProgressBar( } else { 0f } + val trickplayTile = if (isSeeking) { + resolveTrickplayTile(trickplay, seekPosition.toDouble()) + } else { + null + } - // iOS bottom bar is VStack(spacing: 8): progress slider, then the time row. Column( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - // Scrub preview bubble — pinned above the bar at the drag position, - // clamped so it never runs off-screen (iOS clamps to [80, width-80]pt). Box(modifier = Modifier.fillMaxWidth()) { if (isSeeking) { val density = LocalDensity.current @@ -115,6 +120,13 @@ fun PlayerProgressBar( .background(Color.Black.copy(alpha = 0.82f)) .padding(horizontal = 12.dp, vertical = 6.dp), ) { + if (trickplayTile != null) { + TrickplayTileImage( + tile = trickplayTile, + previewWidth = 176.dp, + modifier = Modifier.padding(bottom = 4.dp), + ) + } Text( text = formatClockTime(seekPosition.toDouble()), fontSize = 19.sp, @@ -154,9 +166,6 @@ fun PlayerProgressBar( activeTrackColor = MaterialTheme.colorScheme.primary, inactiveTrackColor = Color.White.copy(alpha = 0.3f), ), - // iOS-style dot instead of Material's chunky pill: a small circle - // that grows slightly while scrubbing (the target time shows in the - // floating preview bubble above). Ignores the SliderState param. thumb = { Box( modifier = Modifier @@ -173,7 +182,6 @@ fun PlayerProgressBar( .background(Color.White.copy(alpha = 0.16f)) .onSizeChanged { barWidthPx = it.width.toFloat() }, ) { - // Buffered-ahead: downloaded and safe to seek into. Box( modifier = Modifier .fillMaxWidth(bufferedFraction) @@ -224,7 +232,6 @@ fun PlayerProgressBar( } } } - // Played. Box( modifier = Modifier .fillMaxWidth(playedFraction) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt index 237b3295e..257d170b8 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt @@ -1508,6 +1508,7 @@ fun PlayerScreen( onSelectSubtitle = { viewModel.onSelectSubtitle(it) }, onSelectAudio = { viewModel.onSelectAudio(it) }, onSelectVersion = { viewModel.onSelectVersion(it) }, + onSelectQuality = { viewModel.switchQuality(it) }, modifier = playerOverlayModifier, ) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModel.kt index a9a84228a..ffc87b732 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModel.kt @@ -277,6 +277,7 @@ class PlayerViewModel( // Google Cast (Chromecast) Tier-2 session preparer. Optional so existing // unit tests that construct the VM directly stay source-compatible. private val castPlaybackPreparer: org.prairieserver.prairie.common.player.cast.CastPlaybackPreparer? = null, + private val qualityLadderClient: org.prairieserver.prairie.playback.QualityLadderClient? = null, ) : ViewModel() { // Last load request, replayed by the "Can't reach server" Retry / Try Anyway. @@ -425,6 +426,11 @@ class PlayerViewModel( val isBuffering: Boolean = false, val versions: List = emptyList(), val selectedVersionIndex: Int = 0, + /** In-player encode quality ladder (Auto / Original / rungs). */ + val qualityOptions: List = emptyList(), + val selectedQualityId: String = "auto", + /** Trickplay sprite metadata for the active file version (scrub previews). */ + val trickplay: org.prairieserver.prairie.playback.TrickplayInfo? = null, val contentId: String = "", val seriesId: String? = null, val seasonNumber: Int? = null, @@ -777,6 +783,12 @@ class PlayerViewModel( private var loadJob: Job? = null init { + // Prefetch quality ladder so the in-player Quality sheet has rungs ready. + viewModelScope.launch { + val client = qualityLadderClient ?: return@launch + val ladder = client.fetch() + refreshQualityOptions(ladder = ladder) + } // Reclaim-Watched must never delete the file the player is using // (reachable via PiP -> Downloads). Mirror the currently-playing file // id — from EVERY load path, incl. offline — into the process-wide @@ -1324,6 +1336,13 @@ class PlayerViewModel( chapters = playbackState.chapters.ifEmpty { version?.chapters.orEmpty() }, versions = versions, selectedVersionIndex = versionIndex, + trickplay = version?.trickplay, + qualityOptions = buildQualityMenuFor( + version = version, + playMethod = playbackState.playMethod?.name?.lowercase(), + ), + selectedQualityId = lastLoadArgs?.preferredQuality + ?: _uiState.value.selectedQualityId.ifBlank { "auto" }, seriesId = watchDetail?.seriesId, seasonNumber = watchDetail?.seasonNumber, episodeNumber = watchDetail?.episodeNumber, @@ -3991,6 +4010,72 @@ class PlayerViewModel( */ fun onSelectVersion(index: Int) = startVersionPlayback(index) + /** + * Switch in-player encode quality (Auto / Original / ladder rung). Restarts + * the session at the current position with the mapped V3 quality preference + * and persists settings-compatible values via [PlayerSettingsStore]. + */ + fun switchQuality(qualityId: String) { + val state = _uiState.value + if (qualityId.equals(state.selectedQualityId, ignoreCase = true)) return + val v3Preference = org.prairieserver.prairie.playback.toV3QualityPreference(qualityId) + _uiState.update { it.copy(selectedQualityId = qualityId) } + viewModelScope.launch { + runCatching { playerSettingsStore.setPreferredQuality(v3Preference) } + sessionLifecycle.stop() + loadContent( + contentId = state.contentId, + preferredFileId = state.mediaFileId, + preferredQuality = v3Preference, + initialAudioTrackIndex = state.selectedAudioIndex, + initialSubtitleTrackIndex = state.selectedSubtitleIndex, + resumePositionOverride = state.position, + suppressResumeRewind = true, + ) + // Restore the full menu id after load (loadContent may only know V3). + _uiState.update { it.copy(selectedQualityId = qualityId) } + } + } + + private fun buildQualityMenuFor( + version: FileVersion?, + playMethod: String?, + ladder: List? = null, + ): List { + val live = ladder + ?: qualityLadderClient?.cachedOrFallback() + ?: org.prairieserver.prairie.playback.FALLBACK_QUALITY_LADDER + val probed = version?.videoTracks?.firstOrNull()?.height + val nativeHeight = org.prairieserver.prairie.playback.sourceHeightForFile( + ladder = live, + resolution = version?.resolution, + probedHeight = probed, + ) + val capped = org.prairieserver.prairie.playback.qualityLadderForSourceHeight(live, nativeHeight) + return org.prairieserver.prairie.playback.buildQualityOptions( + ladder = capped, + nativeHeight = nativeHeight, + playMethod = playMethod, + sourceResolutionLabel = version?.resolution, + sourceBitrateKbps = version?.bitrate ?: 0, + ) + } + + private fun refreshQualityOptions( + ladder: List, + ) { + _uiState.update { state -> + val version = state.versions.getOrNull(state.selectedVersionIndex) + state.copy( + qualityOptions = buildQualityMenuFor( + version = version, + playMethod = state.playMethod?.name?.lowercase(), + ladder = ladder, + ), + ) + } + } + /** * Starts playback of [versions][index]. [isRecovery] marks a re-start of the * previously-playing version after a failed switch: it skips the "already on diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/QualitySelector.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/QualitySelector.kt index 22912b6ab..71f1727e3 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/QualitySelector.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/QualitySelector.kt @@ -10,10 +10,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -21,15 +23,15 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.prairieserver.prairie.model.catalog.FileVersion +import org.prairieserver.prairie.playback.QualityMenuOption /** - * Bottom sheet for selecting a file version (quality/resolution). - * Shows resolution, codec, HDR badge, and file size for each version. + * Bottom sheet for selecting encode quality (Auto / Original / ladder rungs) + * and optionally a file version when multiple encodes exist. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -38,6 +40,9 @@ fun QualitySelector( selectedIndex: Int, onSelect: (Int) -> Unit, onDismiss: () -> Unit, + qualityOptions: List = emptyList(), + selectedQualityId: String = "auto", + onSelectQuality: (String) -> Unit = {}, tabletopPaneHeight: Dp? = null, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -57,45 +62,93 @@ fun QualitySelector( .padding(bottom = 32.dp), ) { PlayerSheetHeader( - title = "Quality", - subtitle = "Choose a source version", + title = if (qualityOptions.isNotEmpty() && versions.size > 1) { + "Quality & Version" + } else if (qualityOptions.isNotEmpty()) { + "Quality" + } else { + "Quality" + }, + subtitle = when { + qualityOptions.isNotEmpty() && versions.size > 1 -> + "Choose a transcode rung or source version" + qualityOptions.isNotEmpty() -> + "Choose a transcode rung" + versions.size > 1 -> + "Choose a source version" + else -> + null + }, onDismiss = onDismiss, ) Spacer(modifier = Modifier.height(8.dp)) - LazyColumn { - itemsIndexed( - versions, - contentType = { _, _ -> "quality-version" }, - ) { index, version -> - val label = buildString { - version.resolution?.let { append(it) } ?: append("Unknown") - if (version.hdr) append(" HDR") + if (qualityOptions.isNotEmpty()) { + LazyColumn { + items(qualityOptions, key = { it.id }) { option -> + QualityOptionRow( + label = option.label, + detail = option.sublabel.ifBlank { null }, + isSelected = option.id.equals(selectedQualityId, ignoreCase = true), + onClick = { + onSelectQuality(option.id) + onDismiss() + }, + ) } + } + } - val detail = buildString { - version.codecVideo?.uppercase()?.let { append(it) } - version.codecAudio?.uppercase()?.let { - if (isNotEmpty()) append(" + ") - append(it) - } - if (version.fileSize > 0) { - if (isNotEmpty()) append(" - ") - append(formatBytes(version.fileSize)) - } - }.ifEmpty { null } - - QualityOptionRow( - label = label, - detail = detail, - isSelected = selectedIndex == index, - onClick = { - onSelect(index) - onDismiss() - }, + if (versions.size > 1) { + if (qualityOptions.isNotEmpty()) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text( + text = "Version", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), ) + Spacer(modifier = Modifier.height(8.dp)) + } + LazyColumn { + itemsIndexed( + versions, + contentType = { _, _ -> "quality-version" }, + ) { index, version -> + val label = buildString { + version.resolution?.let { append(it) } ?: append("Unknown") + if (version.hdr) append(" HDR") + } + val detail = buildString { + version.codecVideo?.uppercase()?.let { append(it) } + version.codecAudio?.uppercase()?.let { + if (isNotEmpty()) append(" + ") + append(it) + } + if (version.fileSize > 0) { + if (isNotEmpty()) append(" - ") + append(formatBytes(version.fileSize)) + } + }.ifEmpty { null } + QualityOptionRow( + label = label, + detail = detail, + isSelected = selectedIndex == index, + onClick = { + onSelect(index) + onDismiss() + }, + ) + } } + } else if (qualityOptions.isEmpty()) { + Text( + text = "No alternate qualities available", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp), + ) } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/di/AndroidTvModule.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/di/AndroidTvModule.kt index 017e4a9c0..82019c934 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/di/AndroidTvModule.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/di/AndroidTvModule.kt @@ -486,6 +486,7 @@ val androidTvModule = module { finalPlaybackPositionWriter = get(), catalogRepository = get(), serverReachabilityMonitor = get(), + qualityLadderClient = get(), launchArgs = params.get(), ) } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt index 5ec29d191..a75651432 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt @@ -184,6 +184,8 @@ internal fun TvPlayerHud( subtitlePresentation: TvSubtitleHudPresentation, stats: PlayerStatsSnapshot, playbackPlan: PlaybackExecutionPlan? = null, + sessionId: String? = null, + playMethodLabel: String? = null, desiredAudioOrdinal: Int? = null, desiredAudioConfirmed: Boolean = false, videoFillMode: VideoFillMode, @@ -492,7 +494,15 @@ internal fun TvPlayerHud( chapters = chapters, ) } - HudTab.Stats -> HudPaneViewport { HudStatsPane(stats) } + HudTab.Stats -> HudPaneViewport { + HudStatsPane( + stats = stats, + sessionId = sessionId, + playMethod = playMethodLabel + ?: playbackPlan?.stream?.playMethod?.name, + positionSec = positionSec, + ) + } HudTab.Video -> HudVideoPane( videoQualities = videoQualities, onSelectVideoQuality = onSelectVideoQuality, @@ -588,7 +598,7 @@ internal fun TvPlayerHud( enum class HudTab(val label: String) { Info("Info"), - Stats("Stats"), + Stats("Stats for nerds"), Video("Video"), Audio("Audio"), Subtitles("Subtitles"), @@ -930,8 +940,21 @@ private fun LabelValueRow(label: String, value: String) { * non-null rows. */ @Composable -private fun HudStatsPane(stats: PlayerStatsSnapshot, modifier: Modifier = Modifier) { - val rows = stats.hudRows() +private fun HudStatsPane( + stats: PlayerStatsSnapshot, + modifier: Modifier = Modifier, + sessionId: String? = null, + playMethod: String? = null, + positionSec: Double? = null, +) { + val rows = buildList { + sessionId?.takeIf { it.isNotBlank() }?.let { add("Session" to it) } + playMethod?.takeIf { it.isNotBlank() }?.let { add("Play method" to it) } + positionSec?.takeIf { it.isFinite() && it >= 0.0 }?.let { + add("Position" to formatHudClock(it)) + } + addAll(stats.hudRows()) + } if (rows.isEmpty()) { HudEmptyStatePane("Stats unavailable", modifier) @@ -957,6 +980,18 @@ private fun HudStatsPane(stats: PlayerStatsSnapshot, modifier: Modifier = Modifi } } +private fun formatHudClock(seconds: Double): String { + val total = seconds.toInt().coerceAtLeast(0) + val h = total / 3600 + val m = (total % 3600) / 60 + val s = total % 60 + return if (h > 0) { + String.format(java.util.Locale.ROOT, "%d:%02d:%02d", h, m, s) + } else { + String.format(java.util.Locale.ROOT, "%d:%02d", m, s) + } +} + /** * Playback-speed presets — aligned to tvOS (0.75 / 1.0 / 1.25 / 1.5 / 2.0). */ @@ -1125,7 +1160,7 @@ private fun HudVideoPane( options = videoQualities.map { HudPickerOption(id = it.id, label = it.label) }, - selectedId = (selectedQuality?.id ?: VIDEO_QUALITY_AUTO_ID), + selectedId = (selectedQuality?.id ?: "auto"), onSelect = { id -> onSelectVideoQuality(id) }, ), ) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt index 59a8b594d..b62c2c97a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt @@ -2071,6 +2071,9 @@ fun TvPlayerScreen( bufferedAheadSec = bufferedAheadSec, chapters = state.chapters, introRange = state.intro, + trickplay = state.trickplay, + isBuffering = state.isBuffering, + sleepTimerState = sleepTimerState, creditsRange = state.credits, recapRange = state.recap, previewRange = state.preview, @@ -2184,6 +2187,8 @@ fun TvPlayerScreen( subtitlePresentation = subtitlePresentation, stats = state.stats, playbackPlan = state.playbackPlan, + sessionId = state.sessionId, + playMethodLabel = state.playMethod?.name, desiredAudioOrdinal = state.desiredAudioOrdinal, desiredAudioConfirmed = state.desiredAudioConfirmed, videoFillMode = state.videoFillMode, @@ -2436,6 +2441,9 @@ private fun TvPlayerIdleOverlay( bufferedAheadSec: Double, chapters: List, introRange: org.prairieserver.prairie.model.catalog.TimeRange?, + trickplay: org.prairieserver.prairie.playback.TrickplayInfo? = null, + isBuffering: Boolean, + sleepTimerState: SleepTimerState, creditsRange: org.prairieserver.prairie.model.catalog.TimeRange?, recapRange: org.prairieserver.prairie.model.catalog.TimeRange?, previewRange: org.prairieserver.prairie.model.catalog.TimeRange?, @@ -2567,7 +2575,20 @@ private fun TvPlayerIdleOverlay( .align(Alignment.BottomCenter) .padding(horizontal = 80.dp, vertical = 40.dp), verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { + if (isScrubbing) { + val tile = org.prairieserver.prairie.playback.resolveTrickplayTile( + trickplay, + scrubPreviewSec, + ) + if (tile != null) { + org.prairieserver.prairie.common.player.TrickplayTileImage( + tile = tile, + previewWidth = 240.dp, + ) + } + } // Interactive scrubber — capsule track with chapter ticks, ±10s // skip, hold-to-auto-seek, and Select to commit. tvOS spec §4.1. TvPlayerScrubber( diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt index 391e67ed8..abd9f900d 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt @@ -12,7 +12,6 @@ import android.util.Log import org.prairieserver.prairie.common.player.SubDiag import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import org.prairieserver.prairie.tv.data.preferences.PlaybackQuality import org.prairieserver.prairie.common.player.PlaybackAnalyticsListener import org.prairieserver.prairie.common.player.PlaybackCapabilityDetector import org.prairieserver.prairie.common.player.PlaybackSessionLifecycle @@ -745,6 +744,7 @@ class TvPlayerViewModel( private val catalogRepository: org.prairieserver.prairie.repository.CatalogRepository, // Pre-play reachability gate (issue #33): drives Retry's fresh probe. private val serverReachabilityMonitor: ServerReachabilityMonitor, + private val qualityLadderClient: org.prairieserver.prairie.playback.QualityLadderClient, private val launchArgs: TvPlayerLaunchArgs, ) : ViewModel() { @@ -812,9 +812,12 @@ class TvPlayerViewModel( private val preferredQuality: String? = launchArgs.preferredQuality // Explicit session-level video-quality intent chosen in the player's // Quality menu. Null uses [preferredQuality] as the default output ceiling. - // Wire values match - // [PlaybackQuality]: "auto"/"original"/"2160p"/"1080p"/"720p"/"480p". + // Menu ids may be full ladder rung ids (`1080p-high`); V3 replan uses + // [org.prairieserver.prairie.playback.toV3QualityPreference]. private var qualityOverride: String? = null + /** Live server ladder (or fallback) used to build the Quality picker. */ + private var qualityLadder: List = + org.prairieserver.prairie.playback.FALLBACK_QUALITY_LADDER private val roomId: String? = launchArgs.roomId private val resumePositionOverride: Double? = launchArgs.resumePositionOverride // The handoff belongs to one cross-screen transition. A recoverable start @@ -974,6 +977,8 @@ class TvPlayerViewModel( /** All server file versions for this item (in-player version switching). */ val fileVersions: List = emptyList(), val selectedFileResolution: String? = null, + /** Trickplay sprite metadata for scrub previews (null when absent). */ + val trickplay: org.prairieserver.prairie.playback.TrickplayInfo? = null, val startPosition: Double = 0.0, val position: Double = 0.0, val duration: Double = 0.0, @@ -1206,8 +1211,18 @@ class TvPlayerViewModel( } val committedQuality = snapshot.transition.committed.qualityPreference if (!snapshot.subtitleApplying && committedQuality != null) { - qualityOverride = committedQuality + val currentMenu = qualityOverride + if (currentMenu == null) { + qualityOverride = committedQuality + } else if ( + org.prairieserver.prairie.playback.toV3QualityPreference(currentMenu) != + committedQuality + ) { + // External replan (advice / track change) moved the preference. + qualityOverride = committedQuality + } } + val selectedMenuId = qualityOverride ?: committedQuality ?: "auto" _uiState.update { state -> state.copy( committedSubtitleIdentity = snapshot.committedIdentity, @@ -1232,7 +1247,15 @@ class TvPlayerViewModel( subtitleRefreshNonce = snapshot.subtitleRefreshNonce .coerceAtMost(Int.MAX_VALUE.toLong()) .toInt(), - videoQualities = state.videoQualities, + videoQualities = if (!snapshot.subtitleApplying && committedQuality != null) { + transcodeQualityLadder( + state.selectedFileResolution, + selectedMenuId, + playMethod = state.playMethod?.name?.lowercase(), + ) + } else { + state.videoQualities + }, ) } }, @@ -1413,6 +1436,21 @@ class TvPlayerViewModel( private val subtitleSnapshotSettlement = TvSubtitleSnapshotSettlementTracker() init { + // Prefetch the server quality ladder so the HUD Quality picker shows + // full bitrate rungs (with fallback until the response lands). + viewModelScope.launch { + qualityLadder = qualityLadderClient.fetch() + val selected = qualityOverride ?: preferredQuality ?: "auto" + _uiState.update { state -> + state.copy( + videoQualities = transcodeQualityLadder( + state.selectedFileResolution, + selected, + playMethod = state.playMethod?.name?.lowercase(), + ), + ) + } + } // Keep the process-wide active-file marker in sync (phone parity), so // Reclaim Watched never deletes bytes under a live player. viewModelScope.launch { @@ -1595,9 +1633,9 @@ class TvPlayerViewModel( sessionId = state.sessionId, positionSeconds = state.position, audioTrackIndex = selectedAudio, - qualityPreference = qualityOverride - ?: preferredQuality - ?: PlaybackQuality.Auto.wireValue, + qualityPreference = org.prairieserver.prairie.playback.toV3QualityPreference( + qualityOverride ?: preferredQuality ?: "auto", + ), subtitleTracks = state.subtitleUrls, audioTracks = version?.audioTracks.orEmpty(), outputRouteGeneration = capabilityDetector.outputRouteGeneration.value, @@ -1836,8 +1874,9 @@ class TvPlayerViewModel( contentId = contentId, preferredFileId = preferredFileIdOverride ?: preferredFileId, preferredQuality = recoveryStartParams?.qualityPreference - ?: qualityOverride - ?: preferredQuality, + ?: org.prairieserver.prairie.playback.toV3QualityPreference( + qualityOverride ?: preferredQuality ?: "auto", + ), ) hasRenderedFirstFrame = false resetSeekRecoveryForContentChange() @@ -1895,7 +1934,9 @@ class TvPlayerViewModel( }, preferredQualityOverride = recoveryStartParams?.qualityPreference ?: preferredQuality, - playbackQualityIntent = qualityOverride, + playbackQualityIntent = org.prairieserver.prairie.playback.toV3QualityPreference( + qualityOverride ?: preferredQuality ?: "auto", + ), suppressResumeRewind = suppressResumeRewind, force = force, episodeSelectionHandoff = episodeSelectionHandoff, @@ -2095,11 +2136,16 @@ class TvPlayerViewModel( selectedFileId = result.fileId, fileVersions = result.versions, selectedFileResolution = result.fileResolution, - videoQualities = authoritativePlaybackQualityOptions( - available = result.playbackPlanV3?.availableQualities.orEmpty(), - selectedLabel = qualityOverride - ?: preferredQuality - ?: PlaybackQuality.Auto.wireValue, + trickplay = result.versions + .firstOrNull { it.fileId == result.fileId } + ?.trickplay + ?: result.versions.firstOrNull()?.trickplay, + // Server-transcode quality ladder for this source + // (tvOS parity) — replaces adaptive-variant options. + videoQualities = transcodeQualityLadder( + result.fileResolution, + qualityOverride ?: preferredQuality ?: "auto", + playMethod = result.playMethod?.name?.lowercase(), ), mediaFileId = result.mediaFileId, startPosition = result.startPositionSeconds, @@ -4392,18 +4438,77 @@ class TvPlayerViewModel( } /** - * Switch the in-player video quality (tvOS ApplePlaybackQuality parity): pin - * a session-level [qualityOverride] and request a protocol-v3 replan at the - * current position so the server transcodes to the chosen rung (or returns to - * Auto/Original). [wireValue] is a [PlaybackQuality] wire value. + * Switch the in-player video quality (tvOS / web ladder parity): pin a + * session-level [qualityOverride] (full menu id, including high variants) + * and request a protocol-v3 replan at the current position. Ladder rung + * ids that V3 does not understand are mapped via [toV3QualityPreference] + * for the replan while the menu keeps the selected rung highlighted. */ fun switchQuality(wireValue: String) { - val current = qualityOverride ?: preferredQuality ?: PlaybackQuality.Auto.wireValue + val current = qualityOverride ?: preferredQuality ?: "auto" if (wireValue == current) return + qualityOverride = wireValue + val v3Preference = org.prairieserver.prairie.playback.toV3QualityPreference(wireValue) + viewModelScope.launch { + // Persist settings-compatible preferences so the next start + // reuses PlayerSettingsStore preferred quality. + runCatching { playerSettingsStore.setPreferredQuality(v3Preference) } + } + _uiState.update { + it.copy( + videoQualities = transcodeQualityLadder( + it.selectedFileResolution, + wireValue, + playMethod = it.playMethod?.name?.lowercase(), + ), + ) + } val state = _uiState.value playbackMutationFence.beginReplan() launchSubtitleTransaction(state) { - subtitleTransactions.selectQuality(wireValue) + subtitleTransactions.updatePlaybackContext(subtitlePlaybackContext(state)) + subtitleTransactions.selectQuality(v3Preference) + } + } + + /** + * The server-transcode quality ladder for the current source: Auto + Original + * always, plus each downscale rung whose height is below the source (never + * offer an upscale). Built from [QualityLadderClient] (fallback until fetched). + */ + private fun transcodeQualityLadder( + sourceResolution: String?, + selectedWire: String, + playMethod: String? = null, + sourceBitrateKbps: Int = 0, + ): List { + val nativeHeight = org.prairieserver.prairie.playback.sourceHeightForFile( + ladder = qualityLadder, + resolution = sourceResolution, + ) + val capped = org.prairieserver.prairie.playback.qualityLadderForSourceHeight( + qualityLadder, + nativeHeight, + ) + val options = org.prairieserver.prairie.playback.buildQualityOptions( + ladder = capped, + nativeHeight = nativeHeight, + playMethod = playMethod, + sourceResolutionLabel = sourceResolution, + sourceBitrateKbps = sourceBitrateKbps, + ) + val selected = selectedWire.ifBlank { "auto" } + return options.map { option -> + VideoQualityOption( + id = option.id, + label = if (option.sublabel.isNotBlank()) { + "${option.label} · ${option.sublabel}" + } else { + option.label + }, + isSelected = option.id.equals(selected, ignoreCase = true), + resolution = option.resolution.ifBlank { null }, + ) } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt index 13f3f6c5a..078c5af04 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt @@ -17,6 +17,11 @@ val networkModule = module { single { DefaultDeviceLoginApi(get()) } single { CatalogApi(get()) } single { PlaybackApi(get()) } + single { + org.prairieserver.prairie.playback.QualityLadderClient( + fetchResponse = { get().getQualityLadder() }, + ) + } single { PersonalDataApi(get()) } single { CollectionApi(get()) } single { ProfileApi(get()) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/CatalogModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/CatalogModels.kt index a30e8ee26..1b792fff1 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/CatalogModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/CatalogModels.kt @@ -3,6 +3,7 @@ package org.prairieserver.prairie.model.catalog import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement +import org.prairieserver.prairie.playback.TrickplayInfo // --- Browse / Catalog --- @@ -267,6 +268,8 @@ data class FileVersion( @SerialName("effective_audio_track_index") val effectiveAudioTrackIndex: Int? = null, @SerialName("subtitle_tracks") val subtitleTracks: List? = null, val chapters: List? = null, + /** Interval sprite-sheet metadata for seek scrubbing; null when not generated. */ + val trickplay: TrickplayInfo? = null, // --- Whole-book audiobook stitching (see org.prairieserver.prairie.audiobook.AudiobookTimeline) --- // The server has no concept of a whole book: it sends each audiobook file as an // individual FileVersion tagged `presentation_kind == "audiobook_part"` with a diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/PlaybackApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/PlaybackApi.kt index 41a4ecd92..e8dc17bff 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/PlaybackApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/PlaybackApi.kt @@ -2,13 +2,17 @@ package org.prairieserver.prairie.network.api import io.ktor.client.* import io.ktor.client.request.* +import io.ktor.client.request.parameter import io.ktor.http.* import org.prairieserver.prairie.model.playback.PlaybackDecisionResponseV3 import org.prairieserver.prairie.model.playback.PlaybackReplanRequestV3 import org.prairieserver.prairie.model.playback.PlaybackRouteEventV3 import org.prairieserver.prairie.model.playback.PlaybackStartRequestV3 import org.prairieserver.prairie.model.playback.ProgressRequest +import org.prairieserver.prairie.model.playback.TranscodeStartRequest +import org.prairieserver.prairie.model.playback.TranscodeStartResponse import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.playback.QualityLadderResponse class PlaybackApi(private val client: HttpClient) { @@ -49,4 +53,20 @@ class PlaybackApi(private val client: HttpClient) { suspend fun stopPlayback(sessionId: String): ApiResult = safeApiCall { client.delete("/api/v1/playback/$sessionId") } + + suspend fun startTranscode(request: TranscodeStartRequest): ApiResult = safeApiCall { + client.post("/api/v1/playback/transcode/start") { + contentType(ContentType.Application.Json) + setBody(request) + } + } + + /** Server's transcode quality ladder (`GET /api/v1/playback/quality-ladder`). */ + suspend fun getQualityLadder(sourceHeight: Int? = null): ApiResult = safeApiCall { + client.get("/api/v1/playback/quality-ladder") { + if (sourceHeight != null && sourceHeight > 0) { + parameter("source_height", sourceHeight) + } + } + } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/QualityLadder.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/QualityLadder.kt new file mode 100644 index 000000000..438cf7525 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/QualityLadder.kt @@ -0,0 +1,347 @@ +package org.prairieserver.prairie.playback + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.prairieserver.prairie.network.ApiResult +import kotlin.math.abs + +/** One rung of the server's transcode ladder. Key selection on [id] — never label/height. */ +@Serializable +data class QualityLadderRung( + val id: String, + val label: String, + val resolution: String, + val height: Int, + @SerialName("bitrate_kbps") val bitrateKbps: Int, +) + +/** Picker payload from `GET /api/v1/playback/quality-ladder`. */ +@Serializable +data class QualityLadderResponse( + val rungs: List = emptyList(), + val modes: List = emptyList(), + @SerialName("source_height") val sourceHeight: Int? = null, +) + +/** One row in the in-player quality menu. */ +data class QualityMenuOption( + val id: String, + val label: String, + val sublabel: String = "", + val resolution: String = "", + val bitrateKbps: Int = 0, + val isOriginal: Boolean = false, + val isAuto: Boolean = false, +) + +/** + * Resolved encode targets for a menu selection. + * + * Null [QualityTargets] for Original on a direct-play base means drop HLS and + * play the raw file. Remux Original uses [copyVideo]=true. + */ +data class QualityTargets( + val resolution: String, + val bitrateKbps: Int, + val copyVideo: Boolean, +) + +/** + * Fallback ladder when the server cannot be reached. + * Mirrors `internal/playback/quality_ladder.go` and web/smarttv FALLBACK_LADDER. + */ +val FALLBACK_QUALITY_LADDER: List = listOf( + QualityLadderRung("2160p", "4K", "2160p", 2160, 20_000), + QualityLadderRung("1080p-high", "1080p High", "1080p", 1080, 10_000), + QualityLadderRung("1080p", "1080p", "1080p", 1080, 6_000), + QualityLadderRung("720p-high", "720p High", "720p", 720, 4_000), + QualityLadderRung("720p", "720p", "720p", 720, 2_000), + QualityLadderRung("480p", "480p", "480p", 480, 1_500), + QualityLadderRung("420p", "420p", "420p", 420, 720), +) + +val DEFAULT_QUALITY_MODES: List = listOf("auto", "original") + +/** True when every rung is fully populated (all-or-nothing). */ +fun isValidQualityLadder(rungs: List?): Boolean { + if (rungs.isNullOrEmpty()) return false + return rungs.all { rung -> + rung.id.isNotBlank() && + rung.label.isNotBlank() && + rung.resolution.isNotBlank() && + rung.height > 0 && + rung.bitrateKbps > 0 + } +} + +/** + * Caps a ladder to rungs the source can offer (highest first). + * Mirrors server `QualityLadderFor`: omit upscales; +8 tolerance; never empty. + */ +fun qualityLadderForSourceHeight( + ladder: List, + sourceHeight: Int, +): List { + if (sourceHeight <= 0) return ladder.toList() + val out = ladder.filter { it.height <= sourceHeight + 8 } + return out.ifEmpty { listOf(ladder.last()) } +} + +fun formatQualityBitrate(kbps: Int): String { + if (kbps >= 1000) { + val tenths = (kbps + 50) / 100 // round to 0.1 Mbps + return if (tenths % 10 == 0) { + "${tenths / 10} Mbps" + } else { + "${tenths / 10}.${tenths % 10} Mbps" + } + } + return "$kbps kbps" +} + +/** Numeric height for a resolution token, preferring the live ladder. */ +fun resolveNativeHeight(resolution: String, ladder: List): Int { + val needle = resolution.trim().lowercase() + ladder.firstOrNull { it.resolution.equals(needle, ignoreCase = true) && it.height > 0 } + ?.let { return it.height } + + return when (needle) { + "2160p", "4k", "uhd" -> 2160 + "1440p" -> 1440 + "1080p", "fhd" -> 1080 + "720p", "hd" -> 720 + "480p", "sd" -> 480 + "420p" -> 420 + else -> needle.removeSuffix("p").toIntOrNull()?.takeIf { it > 0 } ?: 0 + } +} + +fun sourceHeightForFile( + ladder: List, + resolution: String?, + probedHeight: Int? = null, +): Int { + if (probedHeight != null && probedHeight > 0) return probedHeight + if (resolution.isNullOrBlank()) return 0 + return resolveNativeHeight(resolution, ladder) +} + +private fun playMethodLabel(playMethod: String?): String = when (playMethod?.trim()?.lowercase()) { + "direct" -> "Direct Play" + "remux" -> "Remux" + "transcode" -> "Transcode" + else -> "" +} + +/** + * Builds the quality menu: modes (`auto`, `original`) first, then rungs + * strictly below native height (Original already covers source resolution). + */ +fun buildQualityOptions( + ladder: List, + nativeHeight: Int, + playMethod: String? = null, + sourceResolutionLabel: String? = null, + sourceBitrateKbps: Int = 0, + modes: List = DEFAULT_QUALITY_MODES, +): List { + val options = mutableListOf() + val orderedModes = modes.ifEmpty { DEFAULT_QUALITY_MODES } + + for (mode in orderedModes) { + when (val id = mode.trim().lowercase()) { + "auto" -> options += QualityMenuOption(id = "auto", label = "Auto", isAuto = true) + "original", "source", "max" -> { + val res = sourceResolutionLabel?.trim().orEmpty() + val displayRes = when { + res == "2160p" -> "4K" + res.isEmpty() -> "Original" + else -> res + } + val methodLabel = playMethodLabel(playMethod) + val bitrateLabel = + if (sourceBitrateKbps > 0) formatQualityBitrate(sourceBitrateKbps) else "" + val sublabel = listOf(methodLabel, bitrateLabel).filter { it.isNotEmpty() } + .joinToString(" · ") + options += QualityMenuOption( + id = "original", + label = if (res.isEmpty()) "Original" else "Original ($displayRes)", + sublabel = sublabel, + isOriginal = true, + ) + } + } + } + + if (nativeHeight <= 0) { + for (tier in ladder) { + options += QualityMenuOption( + id = tier.id, + label = tier.label, + sublabel = "~${formatQualityBitrate(tier.bitrateKbps)}", + resolution = tier.resolution, + bitrateKbps = tier.bitrateKbps, + ) + } + return options + } + + for (tier in ladder) { + if (tier.height >= nativeHeight) continue + options += QualityMenuOption( + id = tier.id, + label = tier.label, + sublabel = "~${formatQualityBitrate(tier.bitrateKbps)}", + resolution = tier.resolution, + bitrateKbps = tier.bitrateKbps, + ) + } + return options +} + +/** Best rung at or below [maxHeight] for Auto starts. */ +fun bestAutoRung(ladder: List, maxHeight: Int): QualityLadderRung? { + if (ladder.isEmpty()) return null + if (maxHeight <= 0) return ladder.first() + return ladder.firstOrNull { it.height <= maxHeight + 8 } ?: ladder.last() +} + +/** + * Resolves a quality menu id to transcode targets. + * Returns null for `original` on a direct-play base (caller should drop HLS). + */ +fun resolveQualityTargets( + qualityId: String, + options: List, + playMethod: String?, + ladder: List, + deviceMaxHeight: Int = 0, +): QualityTargets? { + val id = qualityId.trim().lowercase() + if (id == "original") { + val method = playMethod?.trim()?.lowercase().orEmpty() + if (method == "direct") return null + if (method == "remux") { + return QualityTargets(resolution = "", bitrateKbps = 0, copyVideo = true) + } + val top = bestAutoRung(ladder, deviceMaxHeight) + return QualityTargets( + resolution = "", + bitrateKbps = top?.bitrateKbps ?: 0, + copyVideo = false, + ) + } + + if (id == "auto") { + val rung = bestAutoRung(ladder, deviceMaxHeight) + if (rung == null) { + return QualityTargets(resolution = "1080p", bitrateKbps = 6_000, copyVideo = false) + } + return QualityTargets( + resolution = rung.resolution, + bitrateKbps = rung.bitrateKbps, + copyVideo = false, + ) + } + + options.firstOrNull { it.id == qualityId && it.resolution.isNotEmpty() && it.bitrateKbps > 0 } + ?.let { + return QualityTargets( + resolution = it.resolution, + bitrateKbps = it.bitrateKbps, + copyVideo = false, + ) + } + ladder.firstOrNull { it.id == qualityId }?.let { + return QualityTargets( + resolution = it.resolution, + bitrateKbps = it.bitrateKbps, + copyVideo = false, + ) + } + return null +} + +/** + * Maps a ladder menu id to a protocol-v3 `quality_preference` token. + * + * V3 [NormalizeQualityV3] only knows auto/original/2160p/1080p/720p/480p. + * High variants and 420p collapse to their nearest supported resolution so a + * replan still encodes at the intended height; callers that need the exact + * bitrate should also pass [QualityTargets] into `transcode/start`. + */ +fun toV3QualityPreference(qualityId: String): String { + val id = qualityId.trim().lowercase() + return when { + id.isEmpty() || id == "auto" -> "auto" + id == "original" || id == "source" || id == "max" -> "original" + id == "4k" || id == "uhd" || id == "2160p" -> "2160p" + id.startsWith("1080") || id == "fhd" -> "1080p" + id.startsWith("720") || id == "hd" -> "720p" + id.startsWith("480") || id == "sd" || id.startsWith("420") -> "480p" + else -> id + } +} + +/** Parse a ladder response body; invalid bodies yield null so callers can fall back. */ +fun parseQualityLadderResponse(response: QualityLadderResponse): List? = + response.rungs.takeIf(::isValidQualityLadder) + +/** + * Process-wide quality-ladder client: fetch once, cache successes, never cache + * failures (mirrors web/smarttv). + * + * [fetchResponse] is typically [org.prairieserver.prairie.network.api.PlaybackApi.getQualityLadder]. + */ +class QualityLadderClient( + private val fetchResponse: suspend () -> ApiResult, +) { + private val mutex = Mutex() + @Volatile + private var cached: List? = null + + fun cachedOrFallback(sourceHeight: Int = 0): List = + qualityLadderForSourceHeight(cached ?: FALLBACK_QUALITY_LADDER, sourceHeight) + + suspend fun fetch(sourceHeight: Int = 0): List { + val ladder = loadLadder() + return qualityLadderForSourceHeight(ladder, sourceHeight) + } + + fun resetCacheForTests() { + cached = null + } + + private suspend fun loadLadder(): List { + cached?.let { return it } + return mutex.withLock { + cached?.let { return it } + when (val result = fetchResponse()) { + is ApiResult.Success -> { + val rungs = parseQualityLadderResponse(result.data) + if (rungs != null) { + cached = rungs + rungs + } else { + FALLBACK_QUALITY_LADDER + } + } + is ApiResult.Error, is ApiResult.NetworkError -> FALLBACK_QUALITY_LADDER + } + } + } +} + +/** Nearest rung at [resolution] by bitrate distance — mirrors server RungForSession. */ +fun rungForSession( + ladder: List, + resolution: String, + bitrateKbps: Int, +): QualityLadderRung? { + if (resolution.isBlank()) return null + return ladder + .filter { it.resolution.equals(resolution, ignoreCase = true) } + .minByOrNull { abs(it.bitrateKbps - bitrateKbps) } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/Trickplay.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/Trickplay.kt new file mode 100644 index 000000000..3ac9277aa --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/Trickplay.kt @@ -0,0 +1,97 @@ +package org.prairieserver.prairie.playback + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round + +/** + * One sprite sheet covering a contiguous tile range. + * Mirrors server `VersionTrickplaySheet` / web `PlayerTrickplaySheet`. + */ +@Serializable +data class TrickplaySheet( + val index: Int = 0, + val url: String = "", +) + +/** + * Interval sprite-sheet metadata for seek scrubbing previews. + * Mirrors server `VersionTrickplay` / web `PlayerTrickplay`. + */ +@Serializable +data class TrickplayInfo( + @SerialName("interval_seconds") val intervalSeconds: Double = 0.0, + val width: Int = 0, + val height: Int = 0, + @SerialName("tile_columns") val tileColumns: Int = 0, + @SerialName("tile_rows") val tileRows: Int = 0, + @SerialName("thumbnail_count") val thumbnailCount: Int = 0, + val sheets: List = emptyList(), +) + +/** + * Resolved sprite tile for a scrub preview — same math as web + * `resolveTrickplayTile` in SeekBar.tsx. + */ +data class TrickplayTilePreview( + val url: String, + val width: Int, + val height: Int, + /** CSS-style background-position percentages (for Coil alignmentOffset). */ + val backgroundPositionXPercent: Float, + val backgroundPositionYPercent: Float, + /** Columns/rows of the sheet (for backgroundSize = columns*100% × rows*100%). */ + val columns: Int, + val rows: Int, + val col: Int, + val row: Int, +) + +/** + * Resolves which sprite tile covers [seconds], or null when trickplay is absent + * / incomplete. Graceful no-op for missing sheets. + */ +fun resolveTrickplayTile( + trickplay: TrickplayInfo?, + seconds: Double, +): TrickplayTilePreview? { + if (trickplay == null || trickplay.thumbnailCount <= 0 || trickplay.sheets.isEmpty()) { + return null + } + val interval = if (trickplay.intervalSeconds > 0) trickplay.intervalSeconds else 10.0 + val columns = if (trickplay.tileColumns > 0) trickplay.tileColumns else 10 + val rows = if (trickplay.tileRows > 0) trickplay.tileRows else 10 + val width = if (trickplay.width > 0) trickplay.width else 320 + val height = if (trickplay.height > 0) { + trickplay.height + } else { + round(width * 9.0 / 16.0).toInt() + } + val tilesPerSheet = columns * rows + val tileIndex = min( + max(0, floor(seconds / interval).toInt()), + max(0, trickplay.thumbnailCount - 1), + ) + val sheetIndex = tileIndex / tilesPerSheet + val sheet = trickplay.sheets.firstOrNull { it.index == sheetIndex } + if (sheet == null || sheet.url.isBlank()) return null + val local = tileIndex % tilesPerSheet + val col = local % columns + val row = local / columns + val posX = if (columns > 1) (col.toFloat() / (columns - 1)) * 100f else 0f + val posY = if (rows > 1) (row.toFloat() / (rows - 1)) * 100f else 0f + return TrickplayTilePreview( + url = sheet.url, + width = width, + height = height, + backgroundPositionXPercent = posX, + backgroundPositionYPercent = posY, + columns = columns, + rows = rows, + col = col, + row = row, + ) +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PlaybackRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PlaybackRepository.kt index f945055a3..8b19ff377 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PlaybackRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PlaybackRepository.kt @@ -5,8 +5,11 @@ import org.prairieserver.prairie.model.playback.PlaybackReplanRequestV3 import org.prairieserver.prairie.model.playback.PlaybackRouteEventV3 import org.prairieserver.prairie.model.playback.PlaybackStartRequestV3 import org.prairieserver.prairie.model.playback.ProgressRequest +import org.prairieserver.prairie.model.playback.TranscodeStartRequest +import org.prairieserver.prairie.model.playback.TranscodeStartResponse import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.api.PlaybackApi +import org.prairieserver.prairie.playback.QualityLadderResponse class PlaybackRepository( private val playbackApi: PlaybackApi, @@ -39,4 +42,12 @@ class PlaybackRepository( /** Stops an active playback session. */ suspend fun stopPlayback(sessionId: String): ApiResult = playbackApi.stopPlayback(sessionId) + + /** Explicitly requests a transcode session (e.g. for quality changes). */ + suspend fun startTranscode(request: TranscodeStartRequest): ApiResult = + playbackApi.startTranscode(request) + + /** Server's transcode quality ladder for the in-player quality menu. */ + suspend fun getQualityLadder(sourceHeight: Int? = null): ApiResult = + playbackApi.getQualityLadder(sourceHeight) } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/PlaybackApiTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/PlaybackApiTest.kt index fdb7b5e5c..10db8bb2a 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/PlaybackApiTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/PlaybackApiTest.kt @@ -26,25 +26,35 @@ import org.prairieserver.prairie.model.playback.PlaybackRouteEventV3 import org.prairieserver.prairie.model.playback.PlaybackStartRequestV3 import org.prairieserver.prairie.model.playback.SelectedPlaybackTracksV3 import org.prairieserver.prairie.model.playback.SubtitleFidelityPreference +import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.playback.QualityLadderResponse import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue class PlaybackApiTest { private class Captured { var method: HttpMethod? = null var path: String = "" + var query: Map = emptyMap() var body: String = "" } - private fun api(captured: Captured): PlaybackApi { + private fun api( + captured: Captured, + responseBody: String = "{}", + ): PlaybackApi { val client = HttpClient( MockEngine { request -> captured.method = request.method captured.path = request.url.encodedPath + captured.query = request.url.parameters.names() + .associateWith { request.url.parameters[it] } captured.body = request.body.toByteArray().decodeToString() respond( - content = "{}", + content = responseBody, status = HttpStatusCode.OK, headers = headersOf(HttpHeaders.ContentType, "application/json"), ) @@ -150,4 +160,38 @@ class PlaybackApiTest { assertEquals("plan_failed", body["event"]!!.jsonPrimitive.content) assertEquals("tv:hdmi:primary", body["output_context_id"]!!.jsonPrimitive.content) } + + @Test + fun `quality ladder omits source_height when unset or non-positive`() = runTest { + val captured = Captured() + val result = api( + captured, + responseBody = """ + {"rungs":[{"id":"1080p","label":"1080p","resolution":"1080p","height":1080,"bitrate_kbps":6000}], + "modes":["auto","original"],"source_height":1080} + """.trimIndent(), + ).getQualityLadder() + + assertEquals(HttpMethod.Get, captured.method) + assertEquals("/api/v1/playback/quality-ladder", captured.path) + assertTrue(captured.query.isEmpty()) + val success = assertIs>(result) + assertEquals(1, success.data.rungs.size) + assertEquals("1080p", success.data.rungs.single().id) + assertEquals(1080, success.data.sourceHeight) + + val again = Captured() + api(again).getQualityLadder(sourceHeight = 0) + assertTrue(again.query.isEmpty()) + } + + @Test + fun `quality ladder includes source_height query when positive`() = runTest { + val captured = Captured() + api(captured).getQualityLadder(sourceHeight = 2160) + + assertEquals(HttpMethod.Get, captured.method) + assertEquals("/api/v1/playback/quality-ladder", captured.path) + assertEquals("2160", captured.query["source_height"]) + } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/QualityLadderTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/QualityLadderTest.kt new file mode 100644 index 000000000..57a3f33d7 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/QualityLadderTest.kt @@ -0,0 +1,348 @@ +package org.prairieserver.prairie.playback + +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.network.ApiResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class QualityLadderTest { + @Test + fun `isValidQualityLadder rejects empty null blank fields and zero values`() { + assertFalse(isValidQualityLadder(null)) + assertFalse(isValidQualityLadder(emptyList())) + assertFalse( + isValidQualityLadder( + listOf(QualityLadderRung("", "1080p", "1080p", 1080, 6000)), + ), + ) + assertFalse( + isValidQualityLadder( + listOf(QualityLadderRung("1080p", "", "1080p", 1080, 6000)), + ), + ) + assertFalse( + isValidQualityLadder( + listOf(QualityLadderRung("1080p", "1080p", "", 1080, 6000)), + ), + ) + assertFalse( + isValidQualityLadder( + listOf(QualityLadderRung("1080p", "1080p", "1080p", 0, 6000)), + ), + ) + assertFalse( + isValidQualityLadder( + listOf( + QualityLadderRung("1080p", "1080p", "1080p", 1080, 6000), + QualityLadderRung("720p", "720p", "720p", 720, 0), + ), + ), + ) + assertTrue(isValidQualityLadder(FALLBACK_QUALITY_LADDER)) + } + + @Test + fun `qualityLadderForSourceHeight omits upscales and never returns empty`() { + val capped = qualityLadderForSourceHeight(FALLBACK_QUALITY_LADDER, 1080) + assertEquals("1080p-high", capped.first().id) + assertTrue(capped.none { it.height > 1088 }) + assertEquals( + FALLBACK_QUALITY_LADDER.size, + qualityLadderForSourceHeight(FALLBACK_QUALITY_LADDER, 0).size, + ) + // Source below every rung → keep the lowest rung rather than empty. + val tiny = qualityLadderForSourceHeight(FALLBACK_QUALITY_LADDER, 100) + assertEquals(1, tiny.size) + assertEquals(FALLBACK_QUALITY_LADDER.last().id, tiny.single().id) + // +8 tolerance keeps a near-match rung. + val near = qualityLadderForSourceHeight( + listOf(QualityLadderRung("1080p", "1080p", "1080p", 1080, 6000)), + 1075, + ) + assertEquals(1, near.size) + } + + @Test + fun `buildQualityOptions puts modes first and skips native rung`() { + val options = buildQualityOptions( + ladder = FALLBACK_QUALITY_LADDER, + nativeHeight = 2160, + playMethod = "direct", + sourceResolutionLabel = "2160p", + sourceBitrateKbps = 40_000, + ) + assertEquals("auto", options[0].id) + assertEquals("original", options[1].id) + assertTrue(options[1].label.contains("4K")) + assertTrue(options[1].sublabel.contains("Direct Play")) + assertTrue(options[1].sublabel.contains("Mbps")) + assertTrue(options.none { it.id == "2160p" }) + assertTrue(options.any { it.id == "1080p-high" }) + } + + @Test + fun `buildQualityOptions covers mode aliases remux labels and unknown native height`() { + val remux = buildQualityOptions( + ladder = FALLBACK_QUALITY_LADDER, + nativeHeight = 1080, + playMethod = "remux", + sourceResolutionLabel = "1080p", + sourceBitrateKbps = 0, + modes = listOf("auto", "source"), + ) + assertEquals("original", remux[1].id) + assertEquals("Original (1080p)", remux[1].label) + assertEquals("Remux", remux[1].sublabel) + assertTrue(remux.none { it.id == "1080p" || it.id == "1080p-high" }) + + val maxMode = buildQualityOptions( + ladder = FALLBACK_QUALITY_LADDER.take(2), + nativeHeight = 720, + playMethod = "transcode", + sourceResolutionLabel = "720p", + modes = listOf("max"), + ) + assertEquals(1, maxMode.count { it.isOriginal }) + assertTrue(maxMode.single { it.isOriginal }.sublabel.contains("Transcode")) + + // Empty modes → DEFAULT_QUALITY_MODES; unknown play method → empty method label. + val defaults = buildQualityOptions( + ladder = FALLBACK_QUALITY_LADDER.take(1), + nativeHeight = 480, + playMethod = "mystery", + sourceResolutionLabel = "", + modes = emptyList(), + ) + assertEquals("auto", defaults[0].id) + assertEquals("Original", defaults[1].label) + assertEquals("", defaults[1].sublabel) + + // nativeHeight <= 0 includes every rung. + val all = buildQualityOptions( + ladder = FALLBACK_QUALITY_LADDER, + nativeHeight = 0, + modes = listOf("auto"), + ) + assertEquals(1 + FALLBACK_QUALITY_LADDER.size, all.size) + assertEquals("~6 Mbps", all.first { it.id == "1080p" }.sublabel) + } + + @Test + fun `resolveQualityTargets maps rung auto remux and fallbacks`() { + val options = buildQualityOptions(FALLBACK_QUALITY_LADDER, nativeHeight = 2160) + val rung = resolveQualityTargets( + qualityId = "720p-high", + options = options, + playMethod = "direct", + ladder = FALLBACK_QUALITY_LADDER, + ) + assertNotNull(rung) + assertEquals("720p", rung.resolution) + assertEquals(4000, rung.bitrateKbps) + assertFalse(rung.copyVideo) + + assertNull( + resolveQualityTargets( + qualityId = "original", + options = options, + playMethod = "direct", + ladder = FALLBACK_QUALITY_LADDER, + ), + ) + + val remux = resolveQualityTargets( + qualityId = "original", + options = options, + playMethod = "remux", + ladder = FALLBACK_QUALITY_LADDER, + ) + assertNotNull(remux) + assertTrue(remux.copyVideo) + assertEquals(0, remux.bitrateKbps) + + val transcodeOriginal = resolveQualityTargets( + qualityId = "original", + options = options, + playMethod = "transcode", + ladder = FALLBACK_QUALITY_LADDER, + deviceMaxHeight = 720, + ) + assertNotNull(transcodeOriginal) + assertFalse(transcodeOriginal.copyVideo) + assertEquals(4000, transcodeOriginal.bitrateKbps) + + val auto = resolveQualityTargets( + qualityId = "auto", + options = options, + playMethod = "transcode", + ladder = FALLBACK_QUALITY_LADDER, + deviceMaxHeight = 1080, + ) + assertNotNull(auto) + assertEquals("1080p", auto.resolution) + + val autoEmpty = resolveQualityTargets( + qualityId = "auto", + options = emptyList(), + playMethod = null, + ladder = emptyList(), + ) + assertEquals(QualityTargets("1080p", 6_000, copyVideo = false), autoEmpty) + + // Lookup by ladder id when options lack resolution/bitrate. + val fromLadder = resolveQualityTargets( + qualityId = "480p", + options = listOf(QualityMenuOption(id = "480p", label = "480p")), + playMethod = null, + ladder = FALLBACK_QUALITY_LADDER, + ) + assertEquals("480p", fromLadder!!.resolution) + assertEquals(1500, fromLadder.bitrateKbps) + + assertNull( + resolveQualityTargets( + qualityId = "missing", + options = emptyList(), + playMethod = null, + ladder = FALLBACK_QUALITY_LADDER, + ), + ) + } + + @Test + fun `bestAutoRung prefers tallest at or below max with tolerance`() { + assertNull(bestAutoRung(emptyList(), 1080)) + assertEquals("2160p", bestAutoRung(FALLBACK_QUALITY_LADDER, 0)!!.id) + assertEquals("1080p-high", bestAutoRung(FALLBACK_QUALITY_LADDER, 1080)!!.id) + assertEquals("420p", bestAutoRung(FALLBACK_QUALITY_LADDER, 50)!!.id) + assertEquals( + "1080p", + bestAutoRung( + listOf(QualityLadderRung("1080p", "1080p", "1080p", 1080, 6000)), + 1075, + )!!.id, + ) + } + + @Test + fun `resolveNativeHeight and sourceHeightForFile cover aliases and probes`() { + assertEquals(2160, resolveNativeHeight("4K", FALLBACK_QUALITY_LADDER)) + assertEquals(2160, resolveNativeHeight("uhd", FALLBACK_QUALITY_LADDER)) + assertEquals(1440, resolveNativeHeight("1440p", FALLBACK_QUALITY_LADDER)) + assertEquals(1080, resolveNativeHeight("fhd", emptyList())) + assertEquals(720, resolveNativeHeight("hd", emptyList())) + assertEquals(480, resolveNativeHeight("sd", emptyList())) + assertEquals(420, resolveNativeHeight("420p", emptyList())) + assertEquals(540, resolveNativeHeight("540p", emptyList())) + assertEquals(0, resolveNativeHeight("bogus", emptyList())) + assertEquals( + 1080, + resolveNativeHeight("1080p", listOf(QualityLadderRung("x", "x", "1080p", 1080, 1))), + ) + + assertEquals(2160, sourceHeightForFile(FALLBACK_QUALITY_LADDER, "1080p", probedHeight = 2160)) + assertEquals(0, sourceHeightForFile(FALLBACK_QUALITY_LADDER, null)) + assertEquals(0, sourceHeightForFile(FALLBACK_QUALITY_LADDER, " ")) + assertEquals(720, sourceHeightForFile(FALLBACK_QUALITY_LADDER, "720p")) + } + + @Test + fun `toV3QualityPreference collapses high variants`() { + assertEquals("auto", toV3QualityPreference("")) + assertEquals("auto", toV3QualityPreference("auto")) + assertEquals("original", toV3QualityPreference("original")) + assertEquals("original", toV3QualityPreference("source")) + assertEquals("original", toV3QualityPreference("max")) + assertEquals("1080p", toV3QualityPreference("1080p-high")) + assertEquals("1080p", toV3QualityPreference("fhd")) + assertEquals("720p", toV3QualityPreference("720p")) + assertEquals("720p", toV3QualityPreference("hd")) + assertEquals("480p", toV3QualityPreference("420p")) + assertEquals("480p", toV3QualityPreference("sd")) + assertEquals("2160p", toV3QualityPreference("4k")) + assertEquals("2160p", toV3QualityPreference("uhd")) + assertEquals("2160p", toV3QualityPreference("2160p")) + assertEquals("custom", toV3QualityPreference("custom")) + } + + @Test + fun `parseQualityLadderResponse accepts valid server payload`() { + val parsed = parseQualityLadderResponse( + QualityLadderResponse( + rungs = FALLBACK_QUALITY_LADDER, + modes = listOf("auto", "original"), + sourceHeight = 2160, + ), + ) + assertEquals(FALLBACK_QUALITY_LADDER.size, parsed!!.size) + assertNull( + parseQualityLadderResponse( + QualityLadderResponse(rungs = emptyList()), + ), + ) + } + + @Test + fun `QualityLadderClient caches success and falls back on error`() = runTest { + var calls = 0 + val client = QualityLadderClient { + calls++ + ApiResult.Success( + QualityLadderResponse( + rungs = listOf( + QualityLadderRung("1080p", "1080p", "1080p", 1080, 6000), + QualityLadderRung("720p", "720p", "720p", 720, 2000), + ), + ), + ) + } + val first = client.fetch() + val second = client.fetch() + assertEquals(1, calls) + assertEquals(first, second) + assertEquals("1080p", first.first().id) + assertEquals("720p", client.cachedOrFallback(sourceHeight = 720).single().id) + + val failing = QualityLadderClient { ApiResult.Error(500, "err", "fail") } + assertEquals(FALLBACK_QUALITY_LADDER, failing.fetch()) + + val network = QualityLadderClient { ApiResult.NetworkError(RuntimeException("down")) } + assertEquals(FALLBACK_QUALITY_LADDER, network.fetch()) + assertEquals( + "1080p-high", + network.cachedOrFallback(sourceHeight = 1080).first().id, + ) + + val invalid = QualityLadderClient { + ApiResult.Success(QualityLadderResponse(rungs = emptyList())) + } + assertEquals(FALLBACK_QUALITY_LADDER, invalid.fetch()) + + client.resetCacheForTests() + assertEquals(FALLBACK_QUALITY_LADDER.first().id, client.cachedOrFallback().first().id) + client.fetch() + assertEquals(2, calls) + } + + @Test + fun `formatQualityBitrate collapses integers`() { + assertEquals("8 Mbps", formatQualityBitrate(8000)) + assertEquals("1.5 Mbps", formatQualityBitrate(1500)) + assertEquals("20 Mbps", formatQualityBitrate(20_000)) + assertEquals("720 kbps", formatQualityBitrate(720)) + } + + @Test + fun `rungForSession picks nearest bitrate at resolution`() { + assertNull(rungForSession(FALLBACK_QUALITY_LADDER, "", 6000)) + assertNull(rungForSession(FALLBACK_QUALITY_LADDER, "999p", 6000)) + val high = rungForSession(FALLBACK_QUALITY_LADDER, "1080p", 9_500) + assertEquals("1080p-high", high!!.id) + val std = rungForSession(FALLBACK_QUALITY_LADDER, "1080P", 5_000) + assertEquals("1080p", std!!.id) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/TrickplayTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/TrickplayTest.kt new file mode 100644 index 000000000..6c60d6638 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/TrickplayTest.kt @@ -0,0 +1,164 @@ +package org.prairieserver.prairie.playback + +import kotlinx.serialization.encodeToString +import org.prairieserver.prairie.model.catalog.FileVersion +import org.prairieserver.prairie.network.PrairieJson +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TrickplayTest { + private fun trickplay( + interval: Double = 10.0, + columns: Int = 10, + rows: Int = 10, + count: Int = 100, + width: Int = 320, + height: Int = 180, + sheets: List = listOf( + TrickplaySheet(0, "https://cdn.example/sheet0.jpg"), + TrickplaySheet(1, "https://cdn.example/sheet1.jpg"), + ), + ) = TrickplayInfo( + intervalSeconds = interval, + width = width, + height = height, + tileColumns = columns, + tileRows = rows, + thumbnailCount = count, + sheets = sheets, + ) + + @Test + fun `resolveTrickplayTile returns null when absent`() { + assertNull(resolveTrickplayTile(null, 30.0)) + assertNull(resolveTrickplayTile(trickplay(count = 0), 30.0)) + assertNull(resolveTrickplayTile(trickplay(sheets = emptyList()), 30.0)) + } + + @Test + fun `resolveTrickplayTile picks first tile at t0`() { + val tile = resolveTrickplayTile(trickplay(), 0.0)!! + assertEquals("https://cdn.example/sheet0.jpg", tile.url) + assertEquals(0, tile.col) + assertEquals(0, tile.row) + assertEquals(0f, tile.backgroundPositionXPercent) + assertEquals(0f, tile.backgroundPositionYPercent) + assertEquals(10, tile.columns) + assertEquals(10, tile.rows) + assertEquals(320, tile.width) + assertEquals(180, tile.height) + } + + @Test + fun `resolveTrickplayTile uses interval columns and sheet index`() { + // tile 15 → sheet 0, local 15 → col 5, row 1 + val tile = resolveTrickplayTile(trickplay(), 150.0)!! + assertEquals("https://cdn.example/sheet0.jpg", tile.url) + assertEquals(5, tile.col) + assertEquals(1, tile.row) + assertEquals((5f / 9f) * 100f, tile.backgroundPositionXPercent, absoluteTolerance = 0.01f) + assertEquals((1f / 9f) * 100f, tile.backgroundPositionYPercent, absoluteTolerance = 0.01f) + + // tile 100 would be sheet 1; clamp to thumbnail_count-1 = 99 → sheet 0 + // with count=100, tilesPerSheet=100, tile 99 is still sheet 0 + val lastOnFirst = resolveTrickplayTile(trickplay(count = 100), 9999.0)!! + assertEquals("https://cdn.example/sheet0.jpg", lastOnFirst.url) + + // With 50 tiles per sheet (5x10), tile 55 → sheet 1 + val nextSheet = resolveTrickplayTile( + trickplay(columns = 5, rows = 10, count = 200), + 550.0, // floor(550/10)=55 + )!! + assertEquals("https://cdn.example/sheet1.jpg", nextSheet.url) + assertEquals(0, nextSheet.col) // 55 % 50 = 5; 5 % 5 = 0 + assertEquals(1, nextSheet.row) // 5 / 5 = 1 + } + + @Test + fun `resolveTrickplayTile applies defaults for missing geometry`() { + val tile = resolveTrickplayTile( + trickplay( + interval = 0.0, + columns = 0, + rows = 0, + width = 0, + height = 0, + count = 20, + sheets = listOf(TrickplaySheet(0, "https://cdn.example/sheet0.jpg")), + ), + 25.0, // floor(25/10)=2 with default interval + )!! + assertEquals(10, tile.columns) + assertEquals(10, tile.rows) + assertEquals(320, tile.width) + assertEquals(180, tile.height) // round(320 * 9/16) + assertEquals(2, tile.col) + assertEquals(0, tile.row) + } + + @Test + fun `resolveTrickplayTile zeroes background percent for single column or row`() { + val tile = resolveTrickplayTile( + trickplay( + columns = 1, + rows = 1, + count = 1, + sheets = listOf(TrickplaySheet(0, "https://cdn.example/one.jpg")), + ), + 0.0, + )!! + assertEquals(0f, tile.backgroundPositionXPercent) + assertEquals(0f, tile.backgroundPositionYPercent) + assertEquals(0, tile.col) + assertEquals(0, tile.row) + } + + @Test + fun `resolveTrickplayTile clamps negative scrub time to first tile`() { + val tile = resolveTrickplayTile(trickplay(), -5.0)!! + assertEquals(0, tile.col) + assertEquals(0, tile.row) + } + + @Test + fun `resolveTrickplayTile returns null for missing sheet url`() { + assertNull( + resolveTrickplayTile( + trickplay(sheets = listOf(TrickplaySheet(0, ""))), + 0.0, + ), + ) + assertNull( + resolveTrickplayTile( + trickplay(sheets = listOf(TrickplaySheet(2, "https://cdn.example/sheet2.jpg"))), + 0.0, + ), + ) + } + + @Test + fun `TrickplayInfo round-trips on FileVersion`() { + val info = TrickplayInfo( + intervalSeconds = 10.0, + width = 320, + height = 180, + tileColumns = 10, + tileRows = 10, + thumbnailCount = 50, + sheets = listOf(TrickplaySheet(0, "/api/v1/trickplay/sheet0.jpg")), + ) + val encoded = PrairieJson.encodeToString( + FileVersion(fileId = 7, trickplay = info), + ) + assertTrue("trickplay" in encoded) + assertTrue("interval_seconds" in encoded) + assertTrue("tile_columns" in encoded) + val decoded = PrairieJson.decodeFromString(encoded) + assertEquals(info, decoded.trickplay) + + val without = PrairieJson.decodeFromString("""{"file_id":7}""") + assertNull(without.trickplay) + } +}