From 63055d176c6dea8675114f675bafaecdb6d4e4b2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 01:01:06 +0000 Subject: [PATCH 1/3] feat(player): quality ladder, trickplay scrub, stats for nerds, androidtv header Add shared QualityLadder client (GET /playback/quality-ladder + fallback) and wire Auto/Original/rung menus into phone QualitySelector and TV HUD Video pane. Parse FileVersion.trickplay and show sprite-tile scrub previews on phone and TV. Rename playback stats entry to "Stats for nerds" with session/method/position. Align Android TV device platform header to androidtv (no hyphen). Co-authored-by: Jonah May --- .../network/AndroidDeviceMetadataProvider.kt | 2 +- .../common/player/PlaybackSessionManager.kt | 17 +- .../common/player/TrickplayTileImage.kt | 55 +++ .../prairie/android/di/AndroidModule.kt | 1 + .../ui/screens/player/PlaybackStatsSheet.kt | 12 +- .../ui/screens/player/PlayerControls.kt | 2 + .../ui/screens/player/PlayerOverlay.kt | 11 +- .../ui/screens/player/PlayerProgressBar.kt | 39 +- .../android/ui/screens/player/PlayerScreen.kt | 1 + .../ui/screens/player/PlayerSettingsSheet.kt | 2 +- .../ui/screens/player/PlayerViewModel.kt | 85 +++++ .../ui/screens/player/QualitySelector.kt | 123 ++++--- .../prairie/tv/di/AndroidTvModule.kt | 3 +- .../tv/ui/screens/player/TvPlayerHud.kt | 45 ++- .../tv/ui/screens/player/TvPlayerScreen.kt | 17 + .../tv/ui/screens/player/TvPlayerViewModel.kt | 139 +++++-- .../prairieserver/prairie/di/NetworkModule.kt | 5 + .../prairie/model/catalog/CatalogModels.kt | 3 + .../prairie/network/api/PlaybackApi.kt | 11 + .../prairie/playback/QualityLadder.kt | 347 ++++++++++++++++++ .../prairie/playback/Trickplay.kt | 97 +++++ .../prairie/repository/PlaybackRepository.kt | 5 + .../prairie/playback/QualityLadderTest.kt | 149 ++++++++ .../prairie/playback/TrickplayTest.kt | 86 +++++ 24 files changed, 1144 insertions(+), 113 deletions(-) create mode 100644 android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/TrickplayTileImage.kt create mode 100644 shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/QualityLadder.kt create mode 100644 shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/Trickplay.kt create mode 100644 shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/QualityLadderTest.kt create mode 100644 shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/TrickplayTest.kt 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 c067c79e8..29d7db7a8 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 @@ -35,7 +35,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 a9924d8d6..9286c92ca 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 @@ -2206,8 +2206,15 @@ open class PlaybackSessionManager( mode: TranscodeMode, audioTrackIndex: Int? = null, subtitleTrackIndex: Int? = null, + targetBitrateKbps: Int? = null, + copyVideo: Boolean = false, ): ApiResult { - val isRemux = mode == TranscodeMode.REMUX + 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, @@ -2217,7 +2224,7 @@ open class PlaybackSessionManager( // (EAC3/TrueHD/DTS). Forcing AAC clobbers the play-method // decision. targetCodecAudio = if (isRemux) "copy" else "aac", - targetBitrateKbps = if (isRemux) 0 else 8000, + targetBitrateKbps = bitrate, segmentDuration = 2, audioTrackIndex = audioTrackIndex, subtitleTrackIndex = subtitleTrackIndex, @@ -2304,6 +2311,8 @@ open class PlaybackSessionManager( mode: TranscodeMode, audioTrackIndex: Int? = null, subtitleTrackIndex: Int? = null, + targetBitrateKbps: Int? = null, + copyVideo: Boolean = false, renewSession: suspend () -> ApiResult, ): ApiResult { val first = startTranscodeFallback( @@ -2313,6 +2322,8 @@ open class PlaybackSessionManager( mode = mode, audioTrackIndex = audioTrackIndex, subtitleTrackIndex = subtitleTrackIndex, + targetBitrateKbps = targetBitrateKbps, + copyVideo = copyVideo, ) if (!first.isPlaybackSessionMissingError()) return first @@ -2326,6 +2337,8 @@ open class PlaybackSessionManager( mode = mode, audioTrackIndex = audioTrackIndex, subtitleTrackIndex = subtitleTrackIndex, + targetBitrateKbps = targetBitrateKbps, + copyVideo = copyVideo, ) if (retry !is ApiResult.Success) { stopSession(renewed.data.sessionId) 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 2fdb061a7..8f363293d 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 @@ -336,6 +336,7 @@ val androidModule = module { finalPlaybackPositionWriter = get(), sectionRepository = get(), castPlaybackPreparer = get(), + qualityLadderClient = get(), ) } viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull()) } 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 fae3882d7..30efc0839 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 @@ -39,6 +39,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, ) { if (!isVisible) return @@ -81,7 +84,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.launch { sheetState.hide() } @@ -97,7 +100,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 b9f5ceebf..397640839 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 @@ -73,6 +73,7 @@ fun PlayerControls( hasMultipleVersions: Boolean, chapters: List = emptyList(), intro: org.prairieserver.prairie.model.catalog.TimeRange? = null, + trickplay: org.prairieserver.prairie.playback.TrickplayInfo? = null, isOrientationLocked: Boolean, // Watch Together guest gate: when false the scrubber + skip buttons are // inert and dimmed (seek is host-only, so disabled for all guests). @@ -238,6 +239,7 @@ fun PlayerControls( enabled = seekEnabled, chapters = chapters, intro = intro, + trickplay = trickplay, ) } } 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 39d5e44a7..53be45e61 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 @@ -41,6 +41,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 @@ -70,6 +71,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, @@ -329,9 +331,10 @@ fun PlayerOverlay( bufferedPosition = state.bufferedPosition, chapters = state.chapters, intro = state.intro, + trickplay = state.trickplay, 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, seekEnabled = seekEnabled, playPauseEnabled = playPauseEnabled, @@ -537,6 +540,9 @@ fun PlayerOverlay( selectedIndex = state.selectedVersionIndex, onSelect = onSelectVersion, onDismiss = { showQualitySelector = false }, + qualityOptions = state.qualityOptions, + selectedQualityId = state.selectedQualityId, + onSelectQuality = onSelectQuality, ) } @@ -585,6 +591,9 @@ fun PlayerOverlay( statsSheetVisible = false settingsSheetVisible = true }, + sessionId = state.sessionId, + playMethod = state.playMethod?.name, + positionLabel = formatClockTime(state.position) + " / " + formatClockTime(state.duration), ) // Chapters picker — opened from the HUD chapters button (HUD product 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 8adc98d12..6a41c69d1 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 @@ -39,20 +39,15 @@ 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`: - * - * - three track regions — played, buffered (safe to seek into), base; - * - the intro range tinted cyan (credits is deliberately NOT drawn — iOS - * only tints the intro); - * - 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). + * iOS `MobilePlayerControls.progressSlider` plus web trickplay scrub previews. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -65,6 +60,7 @@ fun PlayerProgressBar( enabled: Boolean = true, chapters: List = emptyList(), intro: TimeRange? = null, + trickplay: TrickplayInfo? = null, ) { var isSeeking by remember { mutableStateOf(false) } var seekPosition by remember { mutableFloatStateOf(0f) } @@ -75,14 +71,16 @@ fun PlayerProgressBar( val playedFraction = displayPosition / maxDuration val bufferedFraction = (bufferedPosition.toFloat().coerceIn(0f, maxDuration) / maxDuration) .coerceIn(playedFraction, 1f) + 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 @@ -100,6 +98,13 @@ fun PlayerProgressBar( .background(Color.Black.copy(alpha = 0.6f)) .padding(horizontal = 10.dp, vertical = 4.dp), ) { + if (trickplayTile != null) { + TrickplayTileImage( + tile = trickplayTile, + previewWidth = 176.dp, + modifier = Modifier.padding(bottom = 4.dp), + ) + } Text( text = formatClockTime(seekPosition.toDouble()), fontSize = 19.sp, @@ -139,9 +144,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 @@ -158,14 +160,12 @@ fun PlayerProgressBar( .background(Color.White.copy(alpha = 0.24f)) .onSizeChanged { barWidthPx = it.width.toFloat() }, ) { - // Buffered-ahead: downloaded and safe to seek into. Box( modifier = Modifier .fillMaxWidth(bufferedFraction) .fillMaxHeight() .background(Color.White.copy(alpha = 0.45f)), ) - // Intro tint — iOS draws the intro range cyan at 0.4. intro?.let { range -> val startFraction = (range.start / maxDuration).toFloat().coerceIn(0f, 1f) val endFraction = (range.end / maxDuration).toFloat().coerceIn(startFraction, 1f) @@ -181,8 +181,6 @@ fun PlayerProgressBar( ) } } - // Chapter ticks, under the played fill (iOS: the fill - // covers ticks in played territory). if (chapters.isNotEmpty()) { val density = LocalDensity.current val barWidthDp = with(density) { barWidthPx.toDp() } @@ -199,7 +197,6 @@ fun PlayerProgressBar( } } } - // Played. Box( modifier = Modifier .fillMaxWidth(playedFraction) @@ -211,8 +208,6 @@ fun PlayerProgressBar( modifier = Modifier.fillMaxWidth(), ) - // iOS time row: current time left, duration right, `.caption` (~12sp) at - // 0.8 white opacity, monospaced digits. Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, 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 67f66a3ab..1a75ce853 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 @@ -1215,6 +1215,7 @@ fun PlayerScreen( onSelectSubtitle = { viewModel.onSelectSubtitle(it) }, onSelectAudio = { viewModel.onSelectAudio(it) }, onSelectVersion = { viewModel.onSelectVersion(it) }, + onSelectQuality = { viewModel.switchQuality(it) }, ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSettingsSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSettingsSheet.kt index 7f2e7c3dd..b3153fba5 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSettingsSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSettingsSheet.kt @@ -227,7 +227,7 @@ fun PlayerSettingsSheet( ) TapRow( - label = "Playback Stats", + label = "Stats for nerds", subtitle = stats.summaryLabel(), onClick = { scope.launch { sheetState.hide() } 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 36eb5bff3..d87ee3f75 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 @@ -228,6 +228,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. @@ -365,6 +366,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, @@ -684,6 +690,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 @@ -1187,6 +1199,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, @@ -3360,6 +3379,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 7d24e219b..ff1a1c1d9 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 @@ -11,10 +11,12 @@ import androidx.compose.foundation.layout.heightIn 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.ModalBottomSheet @@ -26,10 +28,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll 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 +41,9 @@ fun QualitySelector( selectedIndex: Int, onSelect: (Int) -> Unit, onDismiss: () -> Unit, + qualityOptions: List = emptyList(), + selectedQualityId: String = "auto", + onSelectQuality: (String) -> Unit = {}, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -49,53 +55,88 @@ fun QualitySelector( Column( modifier = Modifier .fillMaxWidth() - // Cap below the top edge + keep content flings from - // dismissing the sheet — see PlayerSheetSupport. .heightIn(max = playerSheetMaxHeight()) .nestedScroll(PlayerSheetFlingGuard) .padding(bottom = 32.dp), ) { - Text( - text = "Quality", - 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") + if (qualityOptions.isNotEmpty()) { + Text( + text = "Quality", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) + Spacer(modifier = Modifier.height(4.dp)) + 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)) + if (versions.size > 1) { + if (qualityOptions.isNotEmpty()) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + Text( + text = if (qualityOptions.isEmpty()) "Quality" else "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") } - }.ifEmpty { null } - - QualityOptionRow( - label = label, - detail = detail, - isSelected = selectedIndex == index, - onClick = { - onSelect(index) - 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() + }, + ) + } } + } else if (qualityOptions.isEmpty()) { + Text( + text = "Quality", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) + 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 8577b2c63..68445e469 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 @@ -138,7 +138,7 @@ val androidTvModule = module { single { AndroidServerSettingsCache(androidContext()) } single { - AndroidDeviceMetadataProvider(androidContext(), platform = "android-tv") + AndroidDeviceMetadataProvider(androidContext(), platform = "androidtv") } // Player infrastructure (duplicate-for-now; extract to :android-player later). single { SubtitleManager(get()) } @@ -460,6 +460,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 0ada48362..343949c7d 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 @@ -151,6 +151,8 @@ internal fun TvPlayerHud( subtitlePresentation: TvSubtitleHudPresentation, stats: PlayerStatsSnapshot, playbackPlan: PlaybackExecutionPlan? = null, + sessionId: String? = null, + playMethodLabel: String? = null, videoFillMode: VideoFillMode, onSelectAudio: (Int) -> Unit, onSelectVideoQuality: (String) -> Unit, @@ -351,7 +353,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, @@ -427,7 +437,7 @@ internal fun TvPlayerHud( enum class HudTab(val label: String) { Info("Info"), - Stats("Stats"), + Stats("Stats for nerds"), Video("Video"), Audio("Audio"), Subtitles("Subtitles"), @@ -738,8 +748,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) @@ -774,6 +797,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). */ @@ -925,7 +960,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 82abeed02..4010d2e0c 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 @@ -1822,6 +1822,7 @@ fun TvPlayerScreen( bufferedAheadSec = bufferedAheadSec, chapters = state.chapters, introRange = state.intro, + trickplay = state.trickplay, isBuffering = state.isBuffering, sleepTimerState = sleepTimerState, // In a room, skip/scrub/seek are routed through the @@ -1931,6 +1932,8 @@ fun TvPlayerScreen( subtitlePresentation = subtitlePresentation, stats = state.stats, playbackPlan = state.playbackPlan, + sessionId = state.sessionId, + playMethodLabel = state.playMethod?.name, videoFillMode = state.videoFillMode, onSelectAudio = viewModel::selectAudioOption, onSelectVideoQuality = { id -> @@ -2255,6 +2258,7 @@ 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, onPlayPause: () -> Unit, @@ -2346,7 +2350,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 0b577e816..aa5ad6c65 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 @@ -10,7 +10,6 @@ import android.os.SystemClock import android.util.Log 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 @@ -505,6 +504,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() { @@ -570,9 +570,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 @@ -699,6 +702,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, @@ -876,8 +881,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, @@ -892,7 +907,11 @@ class TvPlayerViewModel( .coerceAtMost(Int.MAX_VALUE.toLong()) .toInt(), videoQualities = if (!snapshot.subtitleApplying && committedQuality != null) { - transcodeQualityLadder(state.selectedFileResolution, committedQuality) + transcodeQualityLadder( + state.selectedFileResolution, + selectedMenuId, + playMethod = state.playMethod?.name?.lowercase(), + ) } else { state.videoQualities }, @@ -1050,6 +1069,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 { @@ -1206,9 +1240,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, @@ -1406,7 +1440,9 @@ class TvPlayerViewModel( val loadOwner = playbackMutationFence.beginLoad( contentId = contentId, preferredFileId = preferredFileIdOverride ?: preferredFileId, - preferredQuality = qualityOverride ?: preferredQuality, + preferredQuality = org.prairieserver.prairie.playback.toV3QualityPreference( + qualityOverride ?: preferredQuality ?: "auto", + ), ) hasRenderedFirstFrame = false resetSeekRecoveryForContentChange() @@ -1594,11 +1630,16 @@ class TvPlayerViewModel( selectedFileId = result.fileId, fileVersions = result.versions, selectedFileResolution = result.fileResolution, + 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 ?: PlaybackQuality.Auto.wireValue, + qualityOverride ?: preferredQuality ?: "auto", + playMethod = result.playMethod?.name?.lowercase(), ), mediaFileId = result.mediaFileId, startPosition = result.startPositionSeconds, @@ -3251,54 +3292,78 @@ 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() subtitleTransactions.updatePlaybackContext(subtitlePlaybackContext(state)) - subtitleTransactions.selectQuality(wireValue) + 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). Wire values / labels come from [PlaybackQuality]. + * offer an upscale). Built from [QualityLadderClient] (fallback until fetched). */ private fun transcodeQualityLadder( sourceResolution: String?, selectedWire: String, + playMethod: String? = null, + sourceBitrateKbps: Int = 0, ): List { - val sourceHeight = sourceResolution?.filter { it.isDigit() }?.toIntOrNull() ?: Int.MAX_VALUE - val rungs = listOf( - PlaybackQuality.P4K, - PlaybackQuality.P1080, - PlaybackQuality.P720, - PlaybackQuality.P480, - ).filter { tierHeight(it) < sourceHeight } - return (listOf(PlaybackQuality.Auto, PlaybackQuality.Original) + rungs).map { + 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 = it.wireValue, - label = it.label, - isSelected = it.wireValue == selectedWire, - resolution = it.wireValue, + 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 }, ) } } - private fun tierHeight(q: PlaybackQuality): Int = when (q) { - PlaybackQuality.P4K -> 2160 - PlaybackQuality.P1080 -> 1080 - PlaybackQuality.P720 -> 720 - PlaybackQuality.P480 -> 480 - else -> Int.MAX_VALUE - } - /** * Skip the intro now: returns the seek target in seconds so the screen * can call MediaController.seekTo. Returns null if there is no active 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 37378ae10..bdd8e2327 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 924ec0b79..cd56e783e 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 --- @@ -252,6 +253,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 906dc5531..52c6b0515 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,6 +2,7 @@ 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 @@ -13,6 +14,7 @@ import org.prairieserver.prairie.model.playback.StartPlaybackRequest 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) { @@ -67,4 +69,13 @@ class PlaybackApi(private val client: HttpClient) { 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 423a2a5a0..4e3c5aae2 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PlaybackRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PlaybackRepository.kt @@ -14,6 +14,7 @@ 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, @@ -92,6 +93,10 @@ class PlaybackRepository( 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) + } // Mirror the enum's @SerialName wire values explicitly (not name.lowercase()), 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..2ebb2bacf --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/QualityLadderTest.kt @@ -0,0 +1,149 @@ +package org.prairieserver.prairie.playback + +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.network.ApiResult +import kotlin.test.BeforeTest +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 { + @BeforeTest + fun reset() { + // Each test constructs its own client; no shared cache. + } + + @Test + fun `isValidQualityLadder rejects empty and zero bitrate`() { + assertFalse(isValidQualityLadder(emptyList())) + 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`() { + 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, + ) + } + + @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.none { it.id == "2160p" }) + assertTrue(options.any { it.id == "1080p-high" }) + } + + @Test + fun `resolveQualityTargets maps rung and auto`() { + 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) + + assertNull( + resolveQualityTargets( + qualityId = "original", + options = options, + playMethod = "direct", + ladder = FALLBACK_QUALITY_LADDER, + ), + ) + + val auto = resolveQualityTargets( + qualityId = "auto", + options = options, + playMethod = "transcode", + ladder = FALLBACK_QUALITY_LADDER, + deviceMaxHeight = 1080, + ) + assertNotNull(auto) + assertEquals("1080p", auto.resolution) + } + + @Test + fun `toV3QualityPreference collapses high variants`() { + assertEquals("auto", toV3QualityPreference("auto")) + assertEquals("original", toV3QualityPreference("original")) + assertEquals("1080p", toV3QualityPreference("1080p-high")) + assertEquals("720p", toV3QualityPreference("720p")) + assertEquals("480p", toV3QualityPreference("420p")) + assertEquals("2160p", toV3QualityPreference("4k")) + } + + @Test + fun `parseQualityLadderResponse accepts valid server payload`() { + val parsed = parseQualityLadderResponse( + QualityLadderResponse( + rungs = FALLBACK_QUALITY_LADDER, + modes = listOf("auto", "original"), + ), + ) + 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) + + val failing = QualityLadderClient { ApiResult.Error(500, "err", "fail") } + assertEquals(FALLBACK_QUALITY_LADDER, failing.fetch()) + } + + @Test + fun `formatQualityBitrate collapses integers`() { + assertEquals("8 Mbps", formatQualityBitrate(8000)) + assertEquals("1.5 Mbps", formatQualityBitrate(1500)) + assertEquals("720 kbps", formatQualityBitrate(720)) + } +} 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..d637c9404 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/TrickplayTest.kt @@ -0,0 +1,86 @@ +package org.prairieserver.prairie.playback + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TrickplayTest { + private fun trickplay( + interval: Double = 10.0, + columns: Int = 10, + rows: Int = 10, + count: Int = 100, + sheets: List = listOf( + TrickplaySheet(0, "https://cdn.example/sheet0.jpg"), + TrickplaySheet(1, "https://cdn.example/sheet1.jpg"), + ), + ) = TrickplayInfo( + intervalSeconds = interval, + width = 320, + height = 180, + 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) + } + + @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, 0.01f) + assertEquals((1f / 9f) * 100f, tile.backgroundPositionYPercent, 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 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, + ), + ) + } +} From 261d6f81bfd74580badf61ce2476589e3ff57f44 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 01:01:26 +0000 Subject: [PATCH 2/3] fix(tv): map quality preference through V3 ladder ids Ensure preferredQuality and playbackQualityIntent use toV3QualityPreference so high-bitrate ladder rungs replan correctly on TV. Co-authored-by: Jonah May --- .../prairie/tv/ui/screens/player/TvPlayerViewModel.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 aa5ad6c65..943212356 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 @@ -1472,7 +1472,9 @@ class TvPlayerViewModel( audioTrackIndex = initialAudioTrackIndex, subtitleTrackIndex = pendingInitialSubtitleIndex, preferredQualityOverride = preferredQuality, - playbackQualityIntent = qualityOverride, + playbackQualityIntent = org.prairieserver.prairie.playback.toV3QualityPreference( + qualityOverride ?: preferredQuality ?: "auto", + ), suppressResumeRewind = suppressResumeRewind, force = force, ) From f8149d7b4520764652e531b778611f0e8f10dc97 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 01:17:22 +0000 Subject: [PATCH 3/3] test(playback): cover quality ladder and trickplay for Kover gate Extend commonTest coverage for QualityLadder parsing/fallback/options/ targets, Trickplay tile math, and PlaybackApi.getQualityLadder so :shared:koverVerify clears the 95% line floor. Co-authored-by: Jonah May --- .../prairie/network/api/PlaybackApiTest.kt | 48 +++- .../prairie/playback/QualityLadderTest.kt | 217 +++++++++++++++++- .../prairie/playback/TrickplayTest.kt | 86 ++++++- 3 files changed, 336 insertions(+), 15 deletions(-) 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 8cbb29f5c..5eefded00 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 @@ -25,25 +25,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"), ) @@ -138,4 +148,38 @@ class PlaybackApiTest { assertEquals("attempt", body["playback_attempt_id"]!!.jsonPrimitive.content) assertEquals("plan_failed", body["event"]!!.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 index 2ebb2bacf..57a3f33d7 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/QualityLadderTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/QualityLadderTest.kt @@ -2,7 +2,6 @@ package org.prairieserver.prairie.playback import kotlinx.coroutines.test.runTest import org.prairieserver.prairie.network.ApiResult -import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -11,14 +10,30 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class QualityLadderTest { - @BeforeTest - fun reset() { - // Each test constructs its own client; no shared cache. - } - @Test - fun `isValidQualityLadder rejects empty and zero bitrate`() { + 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( @@ -31,7 +46,7 @@ class QualityLadderTest { } @Test - fun `qualityLadderForSourceHeight omits upscales`() { + 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 }) @@ -39,6 +54,16 @@ class QualityLadderTest { 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 @@ -53,12 +78,61 @@ class QualityLadderTest { 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 `resolveQualityTargets maps rung and auto`() { + 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", @@ -69,6 +143,7 @@ class QualityLadderTest { assertNotNull(rung) assertEquals("720p", rung.resolution) assertEquals(4000, rung.bitrateKbps) + assertFalse(rung.copyVideo) assertNull( resolveQualityTargets( @@ -79,6 +154,27 @@ class QualityLadderTest { ), ) + 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, @@ -88,16 +184,89 @@ class QualityLadderTest { ) 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 @@ -106,6 +275,7 @@ class QualityLadderTest { QualityLadderResponse( rungs = FALLBACK_QUALITY_LADDER, modes = listOf("auto", "original"), + sourceHeight = 2160, ), ) assertEquals(FALLBACK_QUALITY_LADDER.size, parsed!!.size) @@ -135,15 +305,44 @@ class QualityLadderTest { 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 index d637c9404..6c60d6638 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/TrickplayTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/TrickplayTest.kt @@ -1,8 +1,12 @@ 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( @@ -10,14 +14,16 @@ class TrickplayTest { 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 = 320, - height = 180, + width = width, + height = height, tileColumns = columns, tileRows = rows, thumbnailCount = count, @@ -41,6 +47,8 @@ class TrickplayTest { assertEquals(0f, tile.backgroundPositionYPercent) assertEquals(10, tile.columns) assertEquals(10, tile.rows) + assertEquals(320, tile.width) + assertEquals(180, tile.height) } @Test @@ -50,8 +58,8 @@ class TrickplayTest { assertEquals("https://cdn.example/sheet0.jpg", tile.url) assertEquals(5, tile.col) assertEquals(1, tile.row) - assertEquals((5f / 9f) * 100f, tile.backgroundPositionXPercent, 0.01f) - assertEquals((1f / 9f) * 100f, tile.backgroundPositionYPercent, 0.01f) + 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 @@ -68,6 +76,52 @@ class TrickplayTest { 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( @@ -83,4 +137,28 @@ class TrickplayTest { ), ) } + + @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) + } }