diff --git a/.superpowers/sdd/2026-07-27-pr108-slice-f-watch-together/task-1-report.md b/.superpowers/sdd/2026-07-27-pr108-slice-f-watch-together/task-1-report.md index 5508e5c20..99cb01a2d 100644 --- a/.superpowers/sdd/2026-07-27-pr108-slice-f-watch-together/task-1-report.md +++ b/.superpowers/sdd/2026-07-27-pr108-slice-f-watch-together/task-1-report.md @@ -45,7 +45,7 @@ owner-driven cancellation remains silent. ### Credential boundary The access token is still required to attempt the room socket but is no longer -placed in the request target. The existing same-origin Silo auth plugin adds +placed in the request target. The existing same-origin Prairie auth plugin adds `Authorization`, `X-Profile-Id`, and `X-Profile-Token` headers. The server currently still requires `room_token`, `profile_id`, and `profile_token` query parameters; the client KDoc records that residual request-target exposure. @@ -213,9 +213,9 @@ The exact-scope validation at connect start is not the final token read: the auth plugin reads the scoped token again while building the request so it can honor rotation. A credential scope can disappear between those reads. The Watch Together connector now marks its pinned request with the internal, -opt-in `requireSiloAuth()` attribute. When that marked request's exact scoped -token is null or blank, `PrairieAuthPlugin` removes Silo headers and throws before -the engine runs. Unmarked public requests and `skipSiloAuth()` paths retain +opt-in `requirePrairieAuth()` attribute. When that marked request's exact scoped +token is null or blank, `PrairieAuthPlugin` removes Prairie headers and throws before +the engine runs. Unmarked public requests and `skipPrairieAuth()` paths retain their existing behavior. ### Correction 2 TDD evidence diff --git a/.superpowers/sdd/2026-07-28-android-tv-active-header-focus-editorial-hero/task-4-report.md b/.superpowers/sdd/2026-07-28-android-tv-active-header-focus-editorial-hero/task-4-report.md new file mode 100644 index 000000000..1a6e46880 --- /dev/null +++ b/.superpowers/sdd/2026-07-28-android-tv-active-header-focus-editorial-hero/task-4-report.md @@ -0,0 +1,172 @@ +# Task 4 Report: Android TV Focus and Phone/TV Hero Verification + +Date: 2026-07-28 +Worktree: `fix/tv-for-you-cold-navigation worktree` +Final local HEAD: `9c251293dc321385ea9be205a0732cc7b14b1251` + +## Summary + +Task 4 verification found one Important review issue in the previously approved Task 1 focus path. I fixed it locally in `9c251293` by preventing non-Search secondary routes from falling through to Home focus when `selectedRoot == null`. The repeat independent review approved the updated diff. + +Automated tests, supply-chain checks, release builds, APK signing verification, and artifact packaging completed. Dedicated emulator smoke did not produce a fully clean pass, so I did not push or update PR #126. + +## Local Commits Added + +- `9c251293 fix(tv): avoid home focus fallback on secondary routes` + +## Verification Commands + +Focused TV regressions: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests "org.prairieserver.prairie.tv.ui.shell.TvTopMenuFocusRequestTest" \ + --tests "org.prairieserver.prairie.tv.ui.shell.TvShellFocusStateTest" \ + --tests "org.prairieserver.prairie.tv.ui.components.TvSkylineUpNavigationTest" \ + --tests "org.prairieserver.prairie.tv.ui.components.TvFocusMarqueeModelTest" \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Result: `BUILD SUCCESSFUL`, 68 tasks executed. + +Focused phone regression: + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests "org.prairieserver.prairie.android.ui.screens.home.FeaturedHeroMetadataTest" \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Result: `BUILD SUCCESSFUL`, 80 tasks executed. + +Supply-chain checks: + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +``` + +Result: both exited 0. Self-test output: `All supply-chain policy self-tests passed`. + +Full release gate: + +```bash +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + :androidApp:assembleRelease \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Result: `BUILD SUCCESSFUL in 6m 29s`, 313 tasks executed. + +The test XML directory immediately after the full release gate only contained focused suites, so I reran the unfiltered unit-test tasks explicitly: + +```bash +./gradlew :androidApp:testDebugUnitTest :androidTvApp:testDebugUnitTest \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Result: `BUILD SUCCESSFUL in 1m 20s`, 104 tasks executed. + +XML totals after explicit unfiltered rerun: + +- `androidApp/build/test-results/testDebugUnitTest`: 85 suites, 477 tests, 0 skipped, 0 failures, 0 errors. +- `androidTvApp/build/test-results/testDebugUnitTest`: 88 suites, 646 tests, 0 skipped, 0 failures, 0 errors. + +Diff hygiene: + +```bash +git diff --check origin/main...HEAD +git status --short --branch +git log --oneline origin/main..HEAD +``` + +Result before writing this report: no whitespace errors; branch ahead of `origin/fix/tv-for-you-cold-navigation`. + +## Independent Review + +Initial review finding: + +- Important: non-Search secondary routes could call `requestMenuFocus(null)` and fall through to Home in `TvTopMenuBar`. + +Fix: + +- Added `TvShellFocusState.requestMenuFocusIfAvailable`. +- Updated content-Up routing in `TvMainShell` to allow null targeting only for Search. +- Added regression coverage in `TvShellFocusStateTest`. + +Repeat review result: + +- No blocking findings. Approved. +- Residual risk noted by reviewer: the content-Up fix is covered by state/helper unit tests, not a full Compose focus integration test. + +## Emulator Smoke + +Physical device safety: + +- ADB showed an unapproved physical TV device; I did not target it. +- TV smoke used the dedicated TV emulator. +- Phone smoke used the dedicated phone emulator. + +TV smoke on final TV APK: + +- Installed `androidTvApp/build/outputs/apk/release/androidTvApp-universal-release.apk` + on the dedicated TV emulator. +- Home: from content card, fresh Up focused the Home top pill. Pass. +- Movies library: from first content card, first Up left focus on the content card; second Up focused the Movies pill. Not a clean pass. +- For You: from content, fresh Up focused the For You control. Pass. +- Calendar: targeted Calendar and entered content; Up focused the in-page `Following` control rather than the top Calendar pill. Not a clean top-pill pass. +- Hold-Up from lower row: not cleanly completed after the caveats above. +- Search route focus: not completed after the caveats above. +- TV hero metadata: visual/UIAutomator dumps showed editorial hero metadata for focused content and no technical badges in the large hero; content cards still show technical badges outside the hero. + +Phone smoke on final phone APK: + +- Installed `androidApp/build/outputs/apk/release/androidApp-universal-release.apk` + on the dedicated phone emulator. +- Opened Libraries > Movies. The Movies library Recommended hero displayed editorial chips `2004`, `7.3`, `War`, `R`, with no generic `Movie` chip, and Play / More Info visible. +- The selected movie did not show a runtime chip in the UIAutomator dump, so the requested movie smoke is partial rather than a full pass. +- Episode carousel page smoke was not completed. +- Narrow viewport wrapping was not completed beyond the default `1080x2400` phone emulator viewport. + +Smoke conclusion: not fully green. PR #126 was not pushed or updated. + +## APK Artifacts + +Built universal minified release APKs from local HEAD `9c251293` with `-PallowDebugReleaseSigning=true`. + +Signing caveat: these are debug-signed release builds. They only upgrade installations signed with the same debug certificate. + +Copied artifacts: + +- `Prairie-Phone-Universal-0.3.11-FocusHeroFix-9c251293.apk` +- `Prairie-TV-Universal-0.3.11-TVFocusHeroFix-9c251293.apk` + +SHA-256: + +- Phone: `0c4c68d15d47c28de41ef1fc1db080ba266f899aa2c3dad663a20fdb5dd5ed50` +- TV: `9e135e3cdad20f1b000ae2ef55573ceb6070e1412718d4ebde47a1977e492a09` + +`apksigner verify --verbose` on copied APKs: + +- Phone: verifies; v2 scheme true; number of signers 1. +- TV: verifies; v2 scheme true; number of signers 1. + +`apkanalyzer` status: + +- `apkanalyzer` failed with `IllegalStateException: Cannot locate latest build tools`, even with `ANDROID_HOME` and `ANDROID_SDK_ROOT` set. +- Used SDK `aapt` as fallback for package/version/ABI metadata. + +`aapt dump badging` metadata: + +- Phone: `applicationId=org.prairieserver.prairie`, `versionCode=14`, `versionName=0.3.11`, native ABIs `arm64-v8a, armeabi-v7a, x86, x86_64`. +- TV: `applicationId=org.prairieserver.prairie`, `versionCode=15`, `versionName=0.3.11`, native ABIs `arm64-v8a, armeabi-v7a, x86, x86_64`. + +## PR Status + +No push performed. + +No PR #126 description update performed because the required emulator smoke was not fully green. diff --git a/android-shared/build.gradle.kts b/android-shared/build.gradle.kts index ee5b590fc..88b0a6c93 100644 --- a/android-shared/build.gradle.kts +++ b/android-shared/build.gradle.kts @@ -130,7 +130,8 @@ kotlin { android { namespace = "org.prairieserver.prairie.common" compileSdk = 36 - sourceSets.getByName("debug").assets.srcDir("$projectDir/schemas") + // Room migration schemas are local-test fixtures shared by both build variants. + sourceSets.getByName("test").assets.srcDir("$projectDir/schemas") defaultConfig { minSdk = 24 // Gate for preferring FFmpeg audio decoders over platform decoders. diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepository.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepository.kt index 27dd3c98f..b7353e40e 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepository.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepository.kt @@ -1,6 +1,6 @@ package org.prairieserver.prairie.common.data.repository -import org.prairieserver.prairie.common.data.db.PrairieDatabase +import org.prairieserver.prairie.common.data.db.SiloDatabase import org.prairieserver.prairie.common.data.db.entity.CatalogCacheEntity import org.prairieserver.prairie.model.catalog.CatalogResponse import org.prairieserver.prairie.model.catalog.EpisodesResponse @@ -9,7 +9,10 @@ import org.prairieserver.prairie.model.catalog.SeasonsResponse import org.prairieserver.prairie.model.personal.UserLibrary import org.prairieserver.prairie.model.section.ResolvedSection import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.repository.port.CatalogCachePort +import org.prairieserver.prairie.repository.port.CatalogCacheWriteLease import kotlinx.serialization.json.Json /** @@ -19,8 +22,9 @@ import kotlinx.serialization.json.Json * rather than crashing the browse screen. */ class RoomCatalogCacheRepository( - db: PrairieDatabase, + db: SiloDatabase, private val snapshotProvider: suspend () -> AuthScopeSnapshot?, + private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), private val now: () -> Long = { System.currentTimeMillis() }, ) : CatalogCachePort { @@ -28,44 +32,84 @@ class RoomCatalogCacheRepository( private val json = Json { ignoreUnknownKeys = true } override suspend fun cacheLibraries(libraries: List) = - put(KEY_LIBRARIES, json.encodeToString(libraries)) + cacheLibraries(libraries, currentWriteLease()) + + override suspend fun cacheLibraries(libraries: List, lease: CatalogCacheWriteLease) = + put(KEY_LIBRARIES, json.encodeToString(libraries), lease) override suspend fun getCachedLibraries(): List? = get(KEY_LIBRARIES)?.let { runCatching { json.decodeFromString>(it) }.getOrNull() } override suspend fun cacheDefaultLibraryPage(libraryId: Int, response: CatalogResponse) = - put(libraryKey(libraryId), json.encodeToString(response)) + cacheDefaultLibraryPage(libraryId, response, currentWriteLease()) + + override suspend fun cacheDefaultLibraryPage( + libraryId: Int, + response: CatalogResponse, + lease: CatalogCacheWriteLease, + ) = put(libraryKey(libraryId), json.encodeToString(response), lease) override suspend fun getCachedDefaultLibraryPage(libraryId: Int): CatalogResponse? = get(libraryKey(libraryId))?.let { runCatching { json.decodeFromString(it) }.getOrNull() } override suspend fun cacheLibrarySections(libraryId: Int, sections: List) = - put(librarySectionsKey(libraryId), json.encodeToString(sections)) + cacheLibrarySections(libraryId, sections, currentWriteLease()) + + override suspend fun cacheLibrarySections( + libraryId: Int, + sections: List, + lease: CatalogCacheWriteLease, + ) = put(librarySectionsKey(libraryId), json.encodeToString(sections), lease) override suspend fun getCachedLibrarySections(libraryId: Int): List? = get(librarySectionsKey(libraryId))?.let { runCatching { json.decodeFromString>(it) }.getOrNull() } override suspend fun cacheItemDetail(contentId: String, detail: ItemDetail) = - put(itemDetailKey(contentId), json.encodeToString(detail)) + cacheItemDetail(contentId, detail, currentWriteLease()) + + override suspend fun cacheItemDetail( + contentId: String, + detail: ItemDetail, + lease: CatalogCacheWriteLease, + ) = put(itemDetailKey(contentId), json.encodeToString(detail), lease) override suspend fun getCachedItemDetail(contentId: String): ItemDetail? = get(itemDetailKey(contentId))?.let { runCatching { json.decodeFromString(it) }.getOrNull() } override suspend fun cacheSeasons(seriesId: String, response: SeasonsResponse) = - put(seasonsKey(seriesId), json.encodeToString(response)) + cacheSeasons(seriesId, response, currentWriteLease()) + + override suspend fun cacheSeasons( + seriesId: String, + response: SeasonsResponse, + lease: CatalogCacheWriteLease, + ) = put(seasonsKey(seriesId), json.encodeToString(response), lease) override suspend fun getCachedSeasons(seriesId: String): SeasonsResponse? = get(seasonsKey(seriesId))?.let { runCatching { json.decodeFromString(it) }.getOrNull() } override suspend fun cacheEpisodes(seriesId: String, seasonNumber: Int, response: EpisodesResponse) = - put(episodesKey(seriesId, seasonNumber), json.encodeToString(response)) + cacheEpisodes(seriesId, seasonNumber, response, currentWriteLease()) + + override suspend fun cacheEpisodes( + seriesId: String, + seasonNumber: Int, + response: EpisodesResponse, + lease: CatalogCacheWriteLease, + ) = put(episodesKey(seriesId, seasonNumber), json.encodeToString(response), lease) override suspend fun getCachedEpisodes(seriesId: String, seasonNumber: Int): EpisodesResponse? = get(episodesKey(seriesId, seasonNumber))?.let { runCatching { json.decodeFromString(it) }.getOrNull() } - private suspend fun put(cacheKey: String, jsonStr: String) { + private suspend fun put( + cacheKey: String, + jsonStr: String, + lease: CatalogCacheWriteLease, + ) { + if (lease.identityGeneration != identityTransitions.generation.value) return val snapshot = snapshotProvider() ?: return val profileId = snapshot.profileId ?: return + if (lease.identityGeneration != identityTransitions.generation.value) return // A Room row must fit SQLite's ~2MB CursorWindow or the *read* throws // SQLiteBlobTooBigException. Big library pages can exceed it, so don't // store an unreadable row — drop any prior row for this key and skip. @@ -84,6 +128,9 @@ class RoomCatalogCacheRepository( ) } + private fun currentWriteLease() = + CatalogCacheWriteLease(identityTransitions.generation.value) + private suspend fun get(cacheKey: String): String? { val snapshot = snapshotProvider() ?: return null val profileId = snapshot.profileId ?: return null diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepository.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepository.kt index 41df86b16..0dd1b6736 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepository.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepository.kt @@ -1,11 +1,14 @@ package org.prairieserver.prairie.common.data.repository -import org.prairieserver.prairie.common.data.db.PrairieDatabase +import org.prairieserver.prairie.common.data.db.SiloDatabase import org.prairieserver.prairie.common.data.db.entity.HomeCacheEntity import org.prairieserver.prairie.model.section.ResolvedSection import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.repository.port.HomeCachePort import org.prairieserver.prairie.repository.port.HomeCacheSnapshot +import org.prairieserver.prairie.repository.port.HomeCacheWriteLease import kotlinx.serialization.json.Json /** @@ -17,8 +20,9 @@ import kotlinx.serialization.json.Json * JSON decodes to null rather than crashing the home screen. */ class RoomHomeCacheRepository( - db: PrairieDatabase, + db: SiloDatabase, private val snapshotProvider: suspend () -> AuthScopeSnapshot?, + private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), private val now: () -> Long = { System.currentTimeMillis() }, ) : HomeCachePort { @@ -26,8 +30,20 @@ class RoomHomeCacheRepository( private val json = Json { ignoreUnknownKeys = true } override suspend fun cacheHome(sections: List) { + cacheHome( + sections = sections, + lease = HomeCacheWriteLease(identityTransitions.generation.value), + ) + } + + override suspend fun cacheHome( + sections: List, + lease: HomeCacheWriteLease, + ) { + if (lease.identityGeneration != identityTransitions.generation.value) return val snapshot = snapshotProvider() ?: return val profileId = snapshot.profileId ?: return + if (lease.identityGeneration != identityTransitions.generation.value) return val sectionsJson = json.encodeToString(sections) // A Room row must fit SQLite's ~2MB CursorWindow or the *read* throws // SQLiteBlobTooBigException. Large reorganized home layouts can exceed diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/di/PlayerInfraModule.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/di/PlayerInfraModule.kt index a939f4b71..3d4fe4d9c 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/di/PlayerInfraModule.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/di/PlayerInfraModule.kt @@ -71,6 +71,10 @@ val playerInfraModule = module { DefaultServerSettingsFlusher( settingsApi = get(), scope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + // Same source the settings store stamps its ops with, so a + // retained retry can tell whether the server it was authored + // against is still the one requests would reach. + getServerUrl = { get().getServerUrl() }, ) } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorker.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorker.kt index df7059a48..b98281945 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorker.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorker.kt @@ -20,6 +20,7 @@ import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager import androidx.work.workDataOf import org.prairieserver.prairie.model.download.DownloadStatus +import org.prairieserver.prairie.model.download.DownloadRecord import org.prairieserver.prairie.repository.DownloadsRepository import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeoutConfig @@ -41,6 +42,16 @@ import java.io.IOException import java.net.URLDecoder import java.util.concurrent.TimeUnit +internal fun DownloadRecord.withWorkerStatus( + status: String, + bytesSent: Long? = null, + fileSize: Long? = null, +): DownloadRecord = copy( + status = status, + bytesSent = bytesSent ?: this.bytesSent, + fileSize = fileSize ?: this.fileSize, +) + /** * Streams `GET /api/v1/downloads/{id}/file` to the local * `/downloads////` @@ -338,7 +349,13 @@ class DownloadWorker( // Best-effort: publish failed state into the repo + sidecar. val record = if (uiPushAllowed(serverId, profileId)) repository.recordForFile(fileId) else null if (record != null) { - repository.upsertLocal(record.copy(status = DownloadStatus.Failed.wire)) + repository.upsertLocal( + record.withWorkerStatus( + status = DownloadStatus.Failed.wire, + bytesSent = 0, + fileSize = 0, + ), + ) } updateSidecarStatus( serverId, profileId, fileId, @@ -361,8 +378,8 @@ class DownloadWorker( profileId: String, fileId: Int, status: String, - bytesSent: Long, - fileSize: Long, + bytesSent: Long? = null, + fileSize: Long? = null, localUri: String? = null, fileName: String? = null, // null = keep existing; "" = clear (download finished/failed); else set. @@ -373,10 +390,10 @@ class DownloadWorker( metadataStore.writeSidecar( serverId, profileId, existing.copy( - record = existing.record.copy( + record = existing.record.withWorkerStatus( status = status, - bytesSent = if (bytesSent > 0) bytesSent else existing.record.bytesSent, - fileSize = if (fileSize > 0) fileSize else existing.record.fileSize, + bytesSent = bytesSent, + fileSize = fileSize, ), localUri = localUri ?: existing.localUri, fileName = fileName?.takeIf { it.isNotBlank() } ?: existing.fileName, @@ -406,8 +423,6 @@ class DownloadWorker( updateSidecarStatus( serverId, profileId, fileId, status = DownloadStatus.Downloading.wire, - bytesSent = 0, - fileSize = 0, localUri = localUri, resumeValidator = validator?.takeIf { it.isNotBlank() } ?: "", ) @@ -441,7 +456,7 @@ class DownloadWorker( companion object { private const val TAG = "DownloadWorker" - const val NOTIFICATION_CHANNEL_ID = "prairie_downloads" + const val NOTIFICATION_CHANNEL_ID = "silo_downloads" const val KEY_DOWNLOAD_ID = "download_id" const val KEY_FILE_ID = "file_id" const val KEY_SERVER_ID = "server_id" diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AudiobookPlayerViewModel.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AudiobookPlayerViewModel.kt index 49059ad0e..5f70fd87f 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AudiobookPlayerViewModel.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AudiobookPlayerViewModel.kt @@ -34,7 +34,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlin.math.abs import kotlin.random.Random @@ -81,6 +80,7 @@ data class AudiobookPlayerUiState( class AudiobookPlayerViewModel( private val catalogRepository: CatalogRepository, private val playbackSessionManager: PlaybackSessionManager, + private val playbackSessionLifecycle: PlaybackSessionLifecycle, private val capabilityDetector: PlaybackCapabilityDetector, private val bookmarksStore: AudiobookBookmarksStore, // Track B: durable position via the unified outbox (replaces AudiobookPositionStore @@ -1274,17 +1274,16 @@ class AudiobookPlayerViewModel( sleepTimerJob?.cancel() positionSaveJob?.cancel() val state = _uiState.value - state.sessionId?.let { sessionId -> - runCatching { - runBlocking(Dispatchers.IO) { - // SINK 1: report the retiring session in part-local space. - reportAndStopSession( - sessionId = sessionId, - positionSeconds = sessionLocalPosition(state), - isPaused = true, - ) - } - } + val sessionId = state.sessionId + if (sessionId != null) { + val positionSeconds = sessionLocalPosition(state) + val isPaused = true + // SINK 1: report the retiring session in part-local space. + playbackSessionLifecycle.reportAndStopExternalSessionAsync( + sessionId = sessionId, + positionSeconds = positionSeconds, + isPaused = isPaused, + ) } super.onCleared() } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicy.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicy.kt index 422742811..74edf0355 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicy.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicy.kt @@ -1,17 +1,10 @@ package org.prairieserver.prairie.common.player -enum class PlaybackBufferMode(val wireValue: String, val label: String) { - QuickStart("quick_start", "Quick start"), - Balanced("balanced", "Balanced"), - SmoothPlayback("smooth_playback", "Smooth playback"); - - companion object { - fun fromWire(value: String?): PlaybackBufferMode = entries.firstOrNull { - it.wireValue == value - } ?: SmoothPlayback - } -} - +/** + * Buffering policy derived from what the player can observe. There is no user + * setting and no server-supplied mode: the numbers follow from the device and + * the stream. + */ data class PlaybackBufferPolicy( val minBufferMs: Int, val maxBufferMs: Int, @@ -20,50 +13,159 @@ data class PlaybackBufferPolicy( val targetBufferBytes: Int, val prioritizeTimeOverSizeThresholds: Boolean, ) { + init { + require(maxBufferMs - minBufferMs == MAX_LOAD_IDLE_MS) { + "idle window must be exactly MAX_LOAD_IDLE_MS; maxBufferMs is derived, never written by hand" + } + } + companion object { - fun forMode( - mode: PlaybackBufferMode, + /** + * How long the load control may stop reading the socket, in MEDIA + * time. + * + * DefaultLoadControl fills to maxBufferMs, then requests nothing until + * the buffer drains below minBufferMs — so the gap between them is + * literally how long the connection sits idle, in the player's media + * clock. Upstream proxies close an idle response body based on WALL + * CLOCK time: nginx's send_timeout defaults to 60s. The old + * hand-written 50s/120s pair left a 70s gap and dropped the connection + * every time the buffer filled on a long direct-play file. + * + * Those two clocks only agree at 1.0x. DefaultLoadControl scales + * minBufferUs for speeds ABOVE 1.0, but not below it, and the UI + * offers rates down to [SLOWEST_PLAYBACK_SPEED] (0.5x, see + * SPEED_PRESETS in TvAudiobookSpeedPanel.kt and the clamp in + * AudiobookSpeedSheet.kt) for audiobooks, which share this load + * control. At 0.5x, one media-time second of idle window takes two + * wall-clock seconds to elapse — so a naive 30s media-time window + * becomes 60s of wall clock, exactly nginx's default send_timeout, + * with zero margin. + * + * 15_000 is that same 30s wall-clock budget scaled down by the + * slowest rate (30_000 * SLOWEST_PLAYBACK_SPEED = 15_000): at 0.5x it + * stretches back out to 30s of wall clock, half of + * ASSUMED_PROXY_SEND_TIMEOUT_MS, so the window holds at every speed + * the UI offers, not just 1.0x. + * + * maxBufferMs is therefore never written by hand; it is always + * minBufferMs + this. Depth can grow without ever widening the window. + */ + const val MAX_LOAD_IDLE_MS = 15_000 + + /** + * The slowest rate the UI lets a viewer select (see SPEED_PRESETS in + * TvAudiobookSpeedPanel.kt and the 0.5f..3.0f clamp in + * AudiobookSpeedSheet.kt). Named so the derivation of + * MAX_LOAD_IDLE_MS above isn't a bare magic number. + */ + const val SLOWEST_PLAYBACK_SPEED = 0.5 + + /** The timeout MAX_LOAD_IDLE_MS is chosen to stay clear of. */ + const val ASSUMED_PROXY_SEND_TIMEOUT_MS = 60_000 + + /** Never buffer less than this, however constrained the device. */ + const val MIN_DEPTH_MS = 20_000 + + /** + * Never buffer more than this even when memory allows. Past a few + * minutes we are mostly prefetching content the viewer may seek away + * from — wasted bandwidth, and wasted allowance on mobile data. + */ + const val MAX_DEPTH_MS = 180_000 + + private const val START_MS = 2_000 + + /** + * After a stall the viewer is watching a spinner, so the cushion we + * rebuild before resuming is deliberately small. + */ + private const val REBUFFER_MS = 5_000 + + fun forConditions( deviceProfile: PlaybackBufferDeviceProfile = PlaybackBufferDeviceProfile.Unknown, - ): PlaybackBufferPolicy = when (mode) { - PlaybackBufferMode.QuickStart -> PlaybackBufferPolicy( - minBufferMs = 30_000, - maxBufferMs = 60_000, - bufferForPlaybackMs = 2_000, - bufferForPlaybackAfterRebufferMs = 6_000, - targetBufferBytes = targetBufferBytes(deviceProfile, low = 32, medium = 64, roomy = 128), - prioritizeTimeOverSizeThresholds = false, - ) - PlaybackBufferMode.Balanced -> PlaybackBufferPolicy( - minBufferMs = 50_000, - maxBufferMs = 120_000, - bufferForPlaybackMs = 3_000, - bufferForPlaybackAfterRebufferMs = 10_000, - targetBufferBytes = targetBufferBytes(deviceProfile, low = 48, medium = 96, roomy = 160), - prioritizeTimeOverSizeThresholds = false, - ) - PlaybackBufferMode.SmoothPlayback -> PlaybackBufferPolicy( - minBufferMs = 90_000, - maxBufferMs = 180_000, - bufferForPlaybackMs = 5_000, - bufferForPlaybackAfterRebufferMs = 15_000, - targetBufferBytes = targetBufferBytes(deviceProfile, low = 64, medium = 128, roomy = 192), + ): PlaybackBufferPolicy { + val depthMs = MAX_DEPTH_MS + return PlaybackBufferPolicy( + minBufferMs = depthMs, + maxBufferMs = depthMs + MAX_LOAD_IDLE_MS, + bufferForPlaybackMs = START_MS, + bufferForPlaybackAfterRebufferMs = REBUFFER_MS, + targetBufferBytes = memoryBudgetBytes(deviceProfile), prioritizeTimeOverSizeThresholds = false, ) } - private fun targetBufferBytes( - deviceProfile: PlaybackBufferDeviceProfile, - low: Int, - medium: Int, - roomy: Int, - ): Int = when { - deviceProfile.isLowRamDevice || deviceProfile.memoryClassMb in 1 until 192 -> low * MIB - deviceProfile.memoryClassMb <= 0 -> low * MIB - deviceProfile.memoryClassMb in 192 until 384 -> medium * MIB - else -> roomy * MIB + /** + * The byte ceiling this device can afford. PrairieLoadControl sizes the + * real target from the stream's bitrate and clamps it to this. + * + * This is a fraction of the app's own heap rather than a pick from + * fixed tiers. A fixed tier either starves a small-heap device (a + * flat 48 MiB floor is half of a 96 MB heap — a real OOM risk) or + * leaves a large-heap device's headroom unused (a flat 160 MiB + * ceiling caps a 1 GB heap the same as a 384 MB one). Scaling with + * memoryClassMb keeps the budget proportionate at both ends without + * hand-picking where the tier boundaries should sit. + * + * The fraction is half the heap, not a quarter. Measured hardware: + * the NVIDIA Shield reports memoryClass=192MB and the Google TV + * Streamer reports memoryClass=384MB, and neither is flagged + * low-RAM. A quarter-heap rule gives the Shield 48 MiB and the + * Streamer 96 MiB — LESS buffer than each device shipped with before + * this policy existed (96 MiB and 160 MiB respectively), the exact + * opposite of scaling correctly from small-memory devices to large + * ones. Half the heap gives the Shield 96 MiB and the Streamer + * 192 MiB (at the cap), both at or above their prior fixed values. + */ + internal fun memoryBudgetBytes(deviceProfile: PlaybackBufferDeviceProfile): Int { + val proportionalBytes = + if (deviceProfile.memoryClassMb > 0) { + deviceProfile.memoryClassMb.toLong() * MIB / MEMORY_BUDGET_HEAP_DIVISOR + } else { + null + } + + if (deviceProfile.isLowRamDevice || deviceProfile.memoryClassMb <= 0) { + // A flat 24 MiB is the conservative fallback for a heap size + // we don't trust at all (unknown, or explicitly flagged + // low-RAM). But when memoryClassMb IS known, even on a + // low-RAM device, ignoring it can hand out MORE than the + // proportional share would — a low-RAM stick reporting 48MB + // would get 24 MiB verbatim, half its heap, exactly the flaw + // the proportional rule exists to remove. Take the smaller + // of the two so the flat fallback only ever tightens the + // budget, never loosens it. + return proportionalBytes + ?.coerceAtMost(LOW_RAM_MEMORY_BUDGET_BYTES.toLong()) + ?.toInt() + ?: LOW_RAM_MEMORY_BUDGET_BYTES + } + return checkNotNull(proportionalBytes) + .coerceIn(MIN_MEMORY_BUDGET_BYTES.toLong(), MAX_MEMORY_BUDGET_BYTES.toLong()) + .toInt() } private const val MIB = 1024 * 1024 + + /** The budget is this fraction (1/2) of the app heap — see [memoryBudgetBytes]. */ + private const val MEMORY_BUDGET_HEAP_DIVISOR = 2L + + /** Never budget less than this, however small the heap. */ + private const val MIN_MEMORY_BUDGET_BYTES = 16 * MIB + + /** Never budget more than this even on a very large heap. */ + private const val MAX_MEMORY_BUDGET_BYTES = 192 * MIB + + /** + * Fixed fallback for devices that report no usable heap size, or that + * flag themselves as low-RAM outright — conservative rather than + * proportional, since half of an unknown heap is not a number worth + * trusting. When memoryClassMb is known, this is only a ceiling on + * the proportional share (see [memoryBudgetBytes]), not a value + * handed out regardless of what the device actually reports. + */ + private const val LOW_RAM_MEMORY_BUDGET_BYTES = 24 * MIB } } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycle.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycle.kt index 176e50002..9b73b6747 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycle.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycle.kt @@ -96,6 +96,8 @@ class PlaybackSessionLifecycle( /** Session [pendingStopJob] is stopping; guarded by `pendingStopLock`. */ private var pendingStopSessionId: String? = null + private val externalFinalizationLock = Any() + private val pendingExternalFinalizations = mutableMapOf() private data class ActiveSessionSnapshot( val state: SessionState, @@ -683,6 +685,47 @@ class PlaybackSessionLifecycle( job.start() } + /** + * Reports and stops a session that remains externally owned. + * + * This does not adopt the session or mutate lifecycle-owned playback state. + * The application scope outlives the external owner, while [NonCancellable] + * and IO dispatch keep its final network writes off teardown callers. + */ + fun reportAndStopExternalSessionAsync( + sessionId: String, + positionSeconds: Double, + isPaused: Boolean, + ) { + val job = synchronized(externalFinalizationLock) { + pendingExternalFinalizations[sessionId] + ?.takeUnless { it.isCompleted } + ?: scope.launch( + context = NonCancellable + Dispatchers.IO, + start = CoroutineStart.LAZY, + ) { + runCatching { + sessionManager.reportProgress( + sessionId = sessionId, + position = positionSeconds, + isPaused = isPaused, + ) + } + runCatching { sessionManager.stopSession(sessionId) } + }.also { + pendingExternalFinalizations[sessionId] = it + } + } + job.invokeOnCompletion { + synchronized(externalFinalizationLock) { + if (pendingExternalFinalizations[sessionId] === job) { + pendingExternalFinalizations.remove(sessionId) + } + } + } + job.start() + } + private suspend fun awaitPendingStop() { val job = synchronized(pendingStopLock) { pendingStopJob } ?: return job.join() 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..fd8cf9e64 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 @@ -93,6 +93,13 @@ open class PlaybackSessionManager( val context: ClientPlaybackContext, val playbackAttemptId: String, val qualityPreference: String, + /** + * The bandwidth cap this attempt started under. Carried on the attempt + * so every replan re-sends it: the cap is a delivery ceiling the server + * applies per request, so omitting it on recovery would silently lift + * the limit for the rest of the session. + */ + val bandwidthCapKbps: Int?, val networkEvidence: PlaybackNetworkSnapshot, val sessionId: String, val plan: PlaybackPlanV3, @@ -203,6 +210,17 @@ open class PlaybackSessionManager( subtitleTrackIndex: Int?, qualityPreference: String?, startPosition: Double?, + /** + * The bandwidth half of the user's quality choice + * (`playback.max_bitrate_kbps`); null is uncapped. + * + * Quality is two axes, and the server applies the cap only from what + * the client sends — nothing on the playback path reads the stored + * setting. Sending the resolution alone means a capped preset like + * "1080p Low" delivers 1080p at whatever bitrate the ladder picks, + * which is the bandwidth the user explicitly declined. + */ + maxBitrateKbps: Int? = null, subtitleFidelityPreference: SubtitleFidelityPreference = SubtitleFidelityPreference.PRESERVE, deferPublication: Boolean = false, ): ApiResult = contentStartMutex.withLock { @@ -236,6 +254,7 @@ open class PlaybackSessionManager( outputRouteGeneration = clientPlaybackContext.output.outputRouteGeneration, metered = network.metered, bandwidthEstimateKbps = network.bandwidthEstimateKbps, + bandwidthCapKbps = maxBitrateKbps?.takeIf { it > 0 }, capabilities = capabilities, clientPlaybackContext = clientPlaybackContext, ) @@ -383,6 +402,7 @@ open class PlaybackSessionManager( context = request.clientPlaybackContext, playbackAttemptId = request.playbackAttemptId, qualityPreference = request.qualityPreference, + bandwidthCapKbps = request.bandwidthCapKbps, networkEvidence = network, sessionId = sessionId, plan = plan, @@ -669,6 +689,10 @@ open class PlaybackSessionManager( outputRouteGeneration = currentContext.output.outputRouteGeneration, metered = network.metered, bandwidthEstimateKbps = network.bandwidthEstimateKbps, + // The cap is a per-request delivery ceiling: omitting it on a + // replan would silently lift the user's bandwidth limit for the + // rest of the session. + bandwidthCapKbps = active.bandwidthCapKbps, selectedTracks = SelectedPlaybackTracksV3( audio = selectedTrackIdentity(active, "audio", audioTrackIndex, active.plan.selectedTracks.audio), subtitle = subtitleTrackIndex?.takeIf { it >= 0 } @@ -1346,6 +1370,7 @@ open class PlaybackSessionManager( outputRouteGeneration = active.context.output.outputRouteGeneration, metered = active.networkEvidence.metered, bandwidthEstimateKbps = active.networkEvidence.bandwidthEstimateKbps, + bandwidthCapKbps = active.bandwidthCapKbps, selectedTracks = active.plan.selectedTracks, failure = PlaybackFailureV3( classification = SEEK_REANCHOR_V3_OPERATION, @@ -1607,6 +1632,7 @@ open class PlaybackSessionManager( // evidence would misinform that decision. metered = network.metered, bandwidthEstimateKbps = network.bandwidthEstimateKbps, + bandwidthCapKbps = active.bandwidthCapKbps, selectedTracks = active.plan.selectedTracks, failure = PlaybackFailureV3( classification = classification, diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControl.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControl.kt index 6abcb2691..521ac2ca6 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControl.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControl.kt @@ -34,31 +34,39 @@ class PrairieLoadControl( 0, false, ) { + @Volatile private var depthMs: Int = policy.minBufferMs + + /** The forward buffer the memory budget currently affords, in ms. */ + internal fun currentDepthMs(): Int = depthMs + override fun calculateTargetBufferBytes( parameters: LoadControl.Parameters, trackSelections: Array, ): Int { - val selectedBitrateBps = trackSelections.sumOf { selection -> - selection?.selectedBitrateBps() ?: 0L - }.takeIf { it > 0L } + val selectedBitrateBps = + selectBufferSizingBitrateBps( + trackSelections.mapNotNull { selection -> + selection?.let { + BufferSizingTrackBitrates( + averageBitrateBps = it.selectedFormat.averageBitrate, + peakBitrateBps = it.selectedFormat.peakBitrate, + latestNetworkEstimateBps = it.latestBitrateEstimate, + ) + } + }, + ) val fallback = super.calculateTargetBufferBytes(parameters, trackSelections) - return calculateBitrateTargetBufferBytes( - selectedBitrateBps = selectedBitrateBps, - desiredForwardBufferMs = policy.minBufferMs, - minimumBytes = MIN_TARGET_BUFFER_BYTES, - maximumBytes = policy.targetBufferBytes, - unknownBitrateFallbackBytes = fallback, - ) - } - - private fun ExoTrackSelection.selectedBitrateBps(): Long { - val format = selectedFormat - return listOf( - latestBitrateEstimate, - format.averageBitrate.toLong(), - format.peakBitrate.toLong(), - format.bitrate.toLong(), - ).maxOrNull()?.coerceAtLeast(0L) ?: 0L + val result = + computeBufferSizing( + selectedBitrateBps = selectedBitrateBps, + desiredDepthMs = policy.minBufferMs, + minimumDepthMs = PlaybackBufferPolicy.MIN_DEPTH_MS, + budgetBytes = policy.targetBufferBytes, + minimumBytes = MIN_TARGET_BUFFER_BYTES, + unknownBitrateFallbackBytes = fallback, + ) + depthMs = result.depth.ms + return result.target.bytes } companion object { @@ -66,6 +74,126 @@ class PrairieLoadControl( } } +internal data class BufferSizingTrackBitrates( + val averageBitrateBps: Int, + val peakBitrateBps: Int, + val latestNetworkEstimateBps: Long, +) + +internal fun selectBufferSizingBitrateBps( + tracks: List, +): Long? { + val mediaBitrateBps = + tracks.mapNotNull { track -> + track.averageBitrateBps.takeIf { it > 0 }?.toLong() + ?: track.peakBitrateBps.takeIf { it > 0 }?.toLong() + } + + if (mediaBitrateBps.isNotEmpty()) { + return mediaBitrateBps.sum() + } + + return tracks + .maxOfOrNull { it.latestNetworkEstimateBps } + ?.takeIf { it > 0L } +} + +/** + * The forward buffer this device can actually hold at this bitrate. + * + * Memory is finite, so a high-bitrate stream genuinely cannot be buffered as + * deeply as a low-bitrate one. Computing that reduction here — rather than + * letting the byte clamp truncate the buffer wherever it happens to land — + * means the resulting depth is a number the code chose and can be reasoned + * about, and it keeps maxBufferMs one idle window above a depth that is real. + * + * The budget is authoritative: when it affords less than [minimumDepthMs], + * that shortfall is reported honestly rather than padded up to a floor the + * loader cannot actually hold — a false-but-round number is worse than an + * honest one `currentDepthMs()` can be trusted to reflect. [minimumDepthMs] + * only bounds the *unknown-bitrate* branch below, where there is no bitrate + * to derive a number from at all. + * + * The division by 115/100 mirrors the same overhead margin + * [calculateBitrateTargetBufferBytes] multiplies back in when it turns a + * depth into bytes. Without it, a budget-derived depth still produces a byte + * figure that overshoots the budget once that margin is applied, silently + * clamps back down to the ceiling, and erases the depth's effect on the byte + * target — the two must agree, or reducing the depth changes nothing. + */ +internal fun affordableDepthMs( + desiredDepthMs: Int, + selectedBitrateBps: Long?, + budgetBytes: Int, + minimumDepthMs: Int, +): Int { + val bitrate = selectedBitrateBps?.takeIf { it > 0L } + ?: return desiredDepthMs.coerceAtLeast(minimumDepthMs) + val affordableMs = budgetBytes.toLong() * 8L * 1_000L * 100L / (bitrate * 115L) + return affordableMs.coerceAtMost(desiredDepthMs.toLong()).toInt() +} + +/** A forward-buffer depth, in milliseconds. Wrapped so it cannot be confused with [BufferTargetBytes]. */ +@JvmInline +internal value class BufferDepthMs(val ms: Int) + +/** A load-control byte target. Wrapped so it cannot be confused with [BufferDepthMs]. */ +@JvmInline +internal value class BufferTargetBytes(val bytes: Int) + +/** + * The composed result of sizing the buffer: the depth chosen and the bytes it + * maps to. Both are wrapped value classes rather than bare `Int`s so that + * assigning the wrong one to the wrong destination — e.g. storing the byte + * target where the depth belongs — is a compile error, not a bug only a test + * exercising the Media3 override could catch. + */ +internal data class BufferSizingResult(val depth: BufferDepthMs, val target: BufferTargetBytes) + +/** + * Composes [affordableDepthMs] and [calculateBitrateTargetBufferBytes] into the + * single decision `calculateTargetBufferBytes` needs: how deep a buffer the + * budget affords, and how many bytes that depth costs at this bitrate. + * + * Kept separate from the Media3 override so it can be tested directly without + * constructing track selections — the override is a thin adapter over this. + */ +internal fun computeBufferSizing( + selectedBitrateBps: Long?, + desiredDepthMs: Int, + minimumDepthMs: Int, + budgetBytes: Int, + minimumBytes: Int, + unknownBitrateFallbackBytes: Int, +): BufferSizingResult { + val depthMs = + affordableDepthMs( + desiredDepthMs = desiredDepthMs, + selectedBitrateBps = selectedBitrateBps, + budgetBytes = budgetBytes, + minimumDepthMs = minimumDepthMs, + ) + val targetBytes = + calculateBitrateTargetBufferBytes( + selectedBitrateBps = selectedBitrateBps, + desiredForwardBufferMs = depthMs, + // calculateBitrateTargetBufferBytes requires maximumBytes >= + // minimumBytes. That holds today only because + // MIN_MEMORY_BUDGET_BYTES and MIN_TARGET_BUFFER_BYTES are both + // exactly 16 MiB — a coincidence a future change to either + // constant could break, throwing IllegalArgumentException on the + // playback thread during track selection. The relation is + // restored by lowering the floor rather than raising the ceiling, + // so the memory budget stays the binding limit: a device whose + // budget is under the nominal floor gets a smaller buffer, never + // one that overruns the heap it was allowed. + minimumBytes = minimumBytes.coerceIn(1, budgetBytes.coerceAtLeast(1)), + maximumBytes = budgetBytes.coerceAtLeast(1), + unknownBitrateFallbackBytes = unknownBitrateFallbackBytes, + ) + return BufferSizingResult(depth = BufferDepthMs(depthMs), target = BufferTargetBytes(targetBytes)) +} + internal fun calculateBitrateTargetBufferBytes( selectedBitrateBps: Long?, desiredForwardBufferMs: Int, @@ -78,7 +206,14 @@ internal fun calculateBitrateTargetBufferBytes( val desiredBytes = selectedBitrateBps?.takeIf { it > 0L }?.let { bitrate -> // 15% allows for container/segment overhead and ordinary bitrate // variance without turning a stream's nominal bitrate into a promise. - (bitrate * desiredForwardBufferMs.toLong() * 115L) / (8L * 1_000L * 100L) + try { + Math.multiplyExact( + Math.multiplyExact(bitrate, desiredForwardBufferMs.toLong()), + 115L, + ) / (8L * 1_000L * 100L) + } catch (_: ArithmeticException) { + Long.MAX_VALUE + } } ?: unknownBitrateFallbackBytes.toLong() return desiredBytes.coerceIn(minimumBytes.toLong(), maximumBytes.toLong()).toInt() } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlaybackService.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlaybackService.kt index a63309431..1a43474ba 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlaybackService.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlaybackService.kt @@ -2,7 +2,6 @@ package org.prairieserver.prairie.common.player import android.content.Intent import androidx.media3.common.C -import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer @@ -18,7 +17,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.prairieserver.prairie.common.BuildConfig @@ -105,13 +103,6 @@ class PrairiePlaybackService : MediaSessionService() { // The sole Media3 player owned by this service. @Volatile private var activePlayer: Player? = null - private val activeContentId = MutableStateFlow(null) - private val contentIdListener = object : Player.Listener { - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - activeContentId.value = mediaItem?.mediaId?.takeIf(String::isNotBlank) - } - } - private val _positionMs = MutableStateFlow(0L) /** @@ -132,8 +123,6 @@ class PrairiePlaybackService : MediaSessionService() { player.addAnalyticsListener(analyticsListener) } activePlayer = player - player.addListener(contentIdListener) - activeContentId.value = player.currentMediaItem?.mediaId?.takeIf(String::isNotBlank) activePlayerHolder.set(player) val count = playerInstanceCount.incrementAndGet() android.util.Log.i( @@ -177,16 +166,14 @@ class PrairiePlaybackService : MediaSessionService() { } } - // Mirror the per-profile SubtitleSyncMs preference into the active + // Mirror the per-device SubtitleSyncMs preference into the active // SubtitleOffsetHolder. The libass renderer reads this value live; // Media3 text sidecars are commonly parsed up front, so changing the // holder alone cannot retime their already-built cue timestamps. // Reprepare at the same position to rebuild those cues while preserving // play/pause intent (the libass clock remains continuous across it). - @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) subtitleSyncJob = scope.launch { - activeContentId - .flatMapLatest(playerSettingsStore::subtitleSyncMsFor) + playerSettingsStore.subtitleSyncMsFlow .distinctUntilChanged() .collect { offsetMs -> val previous = subtitleOffsetHolder.getOffsetMs() diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlayerFactory.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlayerFactory.kt index 279e6b54d..ef89fc53f 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlayerFactory.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlayerFactory.kt @@ -45,7 +45,7 @@ import org.prairieserver.prairie.common.player.audio.PassthroughSuppressingAudio import org.prairieserver.prairie.common.player.subtitle.OffsetSubtitleParserFactory import org.prairieserver.prairie.common.player.subtitle.PgsSupExtractor import org.prairieserver.prairie.common.player.subtitle.SubtitleOffsetHolder -import org.prairieserver.prairie.common.player.video.PrairieMediaCodecVideoRenderer +import org.prairieserver.prairie.common.player.video.SiloMediaCodecVideoRenderer import org.prairieserver.prairie.common.player.video.PlaybackRuntimeCorrectionState import org.prairieserver.prairie.libass.LibassBridge import org.prairieserver.prairie.model.playback.AudioPassthroughCapabilities @@ -70,7 +70,7 @@ import java.util.ArrayList * Bluetooth pair, profile language change) without rebuilding the player. */ @UnstableApi -class PrairiePlayerFactory( +class SiloPlayerFactory( private val context: Context, private val tokenManager: TokenManager, private val subtitleManager: SubtitleManager, @@ -187,7 +187,7 @@ class PrairiePlayerFactory( // Build a single-processor chain that hosts the per-profile audio // delay processor. The chain is owned by the factory and shared // across players because Koin gives us a single DelayAudioProcessor - // instance; in practice PrairiePlaybackService creates exactly + // instance; in practice SiloPlaybackService creates exactly // one ExoPlayer per process so there's no contention. val audioSink: AudioSink = PassthroughSuppressingAudioSink(DefaultAudioSink.Builder(context) .setAudioProcessorChain( @@ -226,7 +226,7 @@ class PrairiePlayerFactory( val platformRenderer = (firstNewRenderer until out.size) .firstOrNull { out[it] is MediaCodecVideoRenderer } ?: return - out[platformRenderer] = PrairieMediaCodecVideoRenderer( + out[platformRenderer] = SiloMediaCodecVideoRenderer( context = context, codecAdapterFactory = codecAdapterFactory, mediaCodecSelector = mediaCodecSelector, @@ -277,7 +277,7 @@ class PrairiePlayerFactory( .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) .build() - val mediaLoadErrorHandlingPolicy = PrairieMediaLoadErrorHandlingPolicy( + val mediaLoadErrorHandlingPolicy = SiloMediaLoadErrorHandlingPolicy( isResumableProgressiveDirectPlay = ::isResumableDirectPlayUri, ) fun correctedMediaSourceFactory( @@ -301,7 +301,7 @@ class PrairiePlayerFactory( .setExtractorFactory(hlsExtractorFactory) .setSubtitleParserFactory(embeddedSubtitleParserFactory) .setLoadErrorHandlingPolicy(mediaLoadErrorHandlingPolicy) - val mediaSourceFactory = PrairieMediaSourceFactory( + val mediaSourceFactory = SiloMediaSourceFactory( defaultFactory = correctedMediaSourceFactory(DolbyVisionTransformMode.DISABLED), correctedFactory = ::correctedMediaSourceFactory, hlsFactory = hlsMediaSourceFactory, @@ -311,15 +311,11 @@ class PrairiePlayerFactory( loadErrorHandlingPolicy = mediaLoadErrorHandlingPolicy, ) - // Staged buffer: start once a modest cushion is ready, wait longer - // after an actual stall, and let playback grow a deeper forward - // buffer in the background. A finite byte cap lets low-bitrate - // streams grow toward the time limit while preventing high-bitrate - // remuxes from filling the app heap on memory-constrained TVs. - val bufferPolicy = PlaybackBufferPolicy.forMode( - PlaybackBufferMode.Balanced, - playbackBufferDeviceProfile(), - ) + // Start on a small cushion and keep filling in the background. Depth is + // bounded by the device's memory budget in PrairieLoadControl; the gap + // between min and max is fixed so the connection is never idle long + // enough for an upstream proxy to close it. + val bufferPolicy = PlaybackBufferPolicy.forConditions(playbackBufferDeviceProfile()) val loadControl = PrairieLoadControl(bufferPolicy) val builder = ExoPlayer.Builder(context, renderersFactory) @@ -460,7 +456,7 @@ class PrairiePlayerFactory( .apply { contentId?.takeIf(String::isNotBlank)?.let(::setMediaId) } .setSubtitleConfigurations(subtitleConfigurations) .setTag( - PrairieMediaTransformTag( + SiloMediaTransformTag( dolbyVisionMode = when { org.prairieserver.prairie.model.playback.CLIENT_DV7_TO_DV81 in transformations -> DolbyVisionTransformMode.PROFILE7_TO_PROFILE81 @@ -526,7 +522,7 @@ class PrairiePlayerFactory( private fun buildAbsoluteUrl(serverUrl: String, streamUrl: String): String = resolvePlaybackStreamUrl(serverUrl, streamUrl) - private class PrairieMediaSourceFactory( + private class SiloMediaSourceFactory( private val defaultFactory: MediaSource.Factory, private val correctedFactory: ( DolbyVisionTransformMode, @@ -579,7 +575,7 @@ class PrairiePlayerFactory( val contentSource = if (contentType == C.CONTENT_TYPE_HLS) { hlsFactory.createMediaSource(contentItem) } else { - val tag = localConfiguration.tag as? PrairieMediaTransformTag + val tag = localConfiguration.tag as? SiloMediaTransformTag mediaSourceFactory(tag).createMediaSource(contentItem) } if (subtitleConfigurations.isEmpty()) return contentSource @@ -592,7 +588,7 @@ class PrairiePlayerFactory( return MergingMediaSource(*sources.toTypedArray()) } - private fun mediaSourceFactory(tag: PrairieMediaTransformTag?): MediaSource.Factory = + private fun mediaSourceFactory(tag: SiloMediaTransformTag?): MediaSource.Factory = correctedFactory( tag?.dolbyVisionMode ?: DolbyVisionTransformMode.DISABLED, tag?.expectedDynamicRange, @@ -663,7 +659,7 @@ class PrairiePlayerFactory( * prefixed with the server base URL; stream-relative paths are prefixed with * the server base URL and the `/api/v1` mount. * - * Shared by [PrairiePlayerFactory] (video) and the audiobook player so both + * Shared by [SiloPlayerFactory] (video) and the audiobook player so both * resolve identically. Players that hand a relative URI straight to Media3 hit * OkHttp's "Malformed URL" because [RoutedDataSource] only prefixes the base * when the factory's serverUrl is set — which the audiobook path never did. diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt index cceb1f1fb..b1b7299b2 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt @@ -6,6 +6,7 @@ import android.net.Uri import android.util.Log import android.view.Gravity import android.view.View +import android.view.ViewTreeObserver import android.widget.FrameLayout import androidx.media3.common.C import androidx.media3.common.Format @@ -39,12 +40,22 @@ import kotlin.math.roundToInt * External subtitles come from the server as URLs that need authentication. * This manager builds subtitle configurations and applies track selection. */ +@UnstableApi +enum class AndroidSubtitlePresentation { + Phone, + Television, +} + @UnstableApi class SubtitleManager( private val libassBridge: LibassBridge? = null, + private val presentation: AndroidSubtitlePresentation = + AndroidSubtitlePresentation.Television, ) { private val videoRectSyncs = WeakHashMap() + /** Test-only execution observer; null in production. */ + internal var postLayoutReconciliationObserver: (() -> Unit)? = null var letterbox: LetterboxInsets = LetterboxInsets.NONE set(value) { @@ -271,7 +282,11 @@ class SubtitleManager( playerView.subtitleView?.let { libassBridge?.attachTo(it) } val existing = videoRectSyncs[playerView] val sync = if (existing?.isDisposed == true || existing == null) { - SubtitleVideoRectSync(playerView).also { + SubtitleVideoRectSync( + playerView = playerView, + presentation = presentation, + onPostLayoutReconciled = { postLayoutReconciliationObserver?.invoke() }, + ).also { it.letterbox = letterbox it.titleSafeFraction = titleSafeFraction videoRectSyncs[playerView] = it @@ -279,7 +294,7 @@ class SubtitleManager( } else { existing } - sync.update() + sync.updateAndReconcileAfterLayout() } private fun buildCaptionStyle(appearance: SubtitleAppearance): CaptionStyleCompat { @@ -326,13 +341,23 @@ class SubtitleManager( } private fun fractionalSizeFor(preset: SubtitleFontSizePreset): Float { - return when (preset) { - SubtitleFontSizePreset.Small -> 20f / 720f - SubtitleFontSizePreset.Medium -> 26f / 720f - SubtitleFontSizePreset.Large -> 32f / 720f - SubtitleFontSizePreset.XLarge -> 40f / 720f - SubtitleFontSizePreset.XXLarge -> 48f / 720f + val numerator = when (presentation) { + AndroidSubtitlePresentation.Phone -> when (preset) { + SubtitleFontSizePreset.Small -> 22.5f + SubtitleFontSizePreset.Medium -> 29.25f + SubtitleFontSizePreset.Large -> 36f + SubtitleFontSizePreset.XLarge -> 45f + SubtitleFontSizePreset.XXLarge -> 54f + } + AndroidSubtitlePresentation.Television -> when (preset) { + SubtitleFontSizePreset.Small -> 20f + SubtitleFontSizePreset.Medium -> 26f + SubtitleFontSizePreset.Large -> 32f + SubtitleFontSizePreset.XLarge -> 40f + SubtitleFontSizePreset.XXLarge -> 48f + } } + return numerator / 720f } private fun bottomPaddingFor(position: SubtitlePositionPreset): Float { @@ -341,7 +366,13 @@ class SubtitleManager( SubtitlePositionPreset.LowerThird -> 0.18f SubtitlePositionPreset.Top -> 0.74f } - return (base - titleSafeFraction).coerceAtLeast(0.02f) + // The title-safe inset moves the subtitle surface in by f on both + // edges, leaving a height of (1 - 2f). Preserve the original physical + // preset by solving f + p(1 - 2f) = base for the new padding p. + val safeFraction = titleSafeFraction + val remainingScale = 1f - 2f * safeFraction + if (remainingScale <= 0f) return base + return ((base - safeFraction) / remainingScale).coerceAtLeast(0.02f) } private fun parseHexColor(hex: String, alpha: Int = 255): Int { @@ -487,6 +518,20 @@ internal fun displayedSubtitleVideoRect( ) } +internal fun selectSubtitleCanvasRect( + resizeMode: Int, + contentFrameRect: SubtitleVideoRect?, + displayedVideoRect: SubtitleVideoRect, +): SubtitleVideoRect = when (resizeMode) { + AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + AspectRatioFrameLayout.RESIZE_MODE_FILL, + -> contentFrameRect?.takeIf { + it.width == displayedVideoRect.width && + it.height == displayedVideoRect.height + } ?: displayedVideoRect + else -> contentFrameRect ?: displayedVideoRect +} + internal fun displayedSubtitleContentFrameRect( viewWidth: Int, viewHeight: Int, @@ -554,11 +599,25 @@ internal fun neutralizeFullWidthCueSizes(cueGroup: CueGroup): CueGroup { } @UnstableApi -private class SubtitleVideoRectSync(playerView: PlayerView) : +private class SubtitleVideoRectSync( + playerView: PlayerView, + private val presentation: AndroidSubtitlePresentation, + private val onPostLayoutReconciled: () -> Unit, +) : View.OnLayoutChangeListener, View.OnAttachStateChangeListener, Player.Listener { + private data class LayoutSnapshot( + val resizeMode: Int, + val playerWidth: Int, + val playerHeight: Int, + val frameLeft: Int, + val frameTop: Int, + val frameWidth: Int, + val frameHeight: Int, + ) + private val playerViewRef = WeakReference(playerView) private val contentFrameRef = WeakReference( playerView.findViewById( @@ -566,6 +625,9 @@ private class SubtitleVideoRectSync(playerView: PlayerView) : ) ) private var observedPlayer: Player? = null + private var reconciliationGeneration = 0L + private var pendingVerification: Runnable? = null + private var appliedPasses = 0 var letterbox: LetterboxInsets = LetterboxInsets.NONE set(value) { @@ -584,6 +646,28 @@ private class SubtitleVideoRectSync(playerView: PlayerView) : var isDisposed: Boolean = false private set + private var pendingPreDrawObserver: ViewTreeObserver? = null + private var pendingPreDrawGeneration = 0L + private val postLayoutUpdate = ViewTreeObserver.OnPreDrawListener { + val generation = pendingPreDrawGeneration + clearPendingPostLayoutUpdate() + if (!isDisposed && generation == reconciliationGeneration) { + update() + val currentPlayerView = playerViewRef.get() + val appliedSnapshot = currentPlayerView?.let(::currentSnapshot) + appliedPasses++ + onPostLayoutReconciled() + if (currentPlayerView != null && appliedSnapshot != null && appliedPasses < 2) { + postSnapshotVerification( + playerView = currentPlayerView, + generation = generation, + appliedSnapshot = appliedSnapshot, + ) + } + } + true + } + init { playerView.addOnLayoutChangeListener(this) playerView.addOnAttachStateChangeListener(this) @@ -611,6 +695,71 @@ private class SubtitleVideoRectSync(playerView: PlayerView) : applyRect(playerView) } + fun updateAndReconcileAfterLayout() { + update() + val playerView = playerViewRef.get() ?: return + if (isDisposed) return + reconciliationGeneration++ + appliedPasses = 0 + clearPendingVerification(playerView) + schedulePreDrawFor(reconciliationGeneration) + } + + private fun schedulePreDrawFor(generation: Long) { + val playerView = playerViewRef.get() ?: return dispose(null) + if (isDisposed || generation != reconciliationGeneration) return + pendingPreDrawObserver?.let { observer -> + if (observer.isAlive) { + pendingPreDrawGeneration = generation + return + } + pendingPreDrawObserver = null + } + val observer = playerView.viewTreeObserver + if (!observer.isAlive) return + pendingPreDrawGeneration = generation + pendingPreDrawObserver = observer + observer.addOnPreDrawListener(postLayoutUpdate) + } + + private fun postSnapshotVerification( + playerView: PlayerView, + generation: Long, + appliedSnapshot: LayoutSnapshot, + ) { + lateinit var verification: Runnable + verification = Runnable { + if (pendingVerification === verification) { + pendingVerification = null + } + val currentPlayerView = playerViewRef.get() + if ( + !isDisposed && + generation == reconciliationGeneration && + appliedPasses < 2 && + currentPlayerView != null && + currentSnapshot(currentPlayerView) != appliedSnapshot + ) { + schedulePreDrawFor(generation) + } + } + pendingVerification = verification + playerView.post(verification) + } + + private fun currentSnapshot(playerView: PlayerView): LayoutSnapshot { + val contentFrame = contentFrameRef.get() + return LayoutSnapshot( + resizeMode = playerView.resizeMode, + playerWidth = playerView.width, + playerHeight = playerView.height, + frameLeft = contentFrame?.left ?: 0, + frameTop = contentFrame?.top ?: 0, + frameWidth = contentFrame?.width ?: 0, + frameHeight = contentFrame?.height ?: 0, + ) + } + override fun onVideoSizeChanged(videoSize: VideoSize) { update() } @@ -655,19 +804,44 @@ private class SubtitleVideoRectSync(playerView: PlayerView) : private fun applyRect(playerView: PlayerView) { val subtitleView = playerView.subtitleView ?: return + val resizeMode = playerView.resizeMode + val gravity = Gravity.TOP or Gravity.START + if ( + presentation == AndroidSubtitlePresentation.Phone && + ( + resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FIT || + resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FILL + ) && + !letterbox.isDetected && + titleSafeFraction <= 0f + ) { + applyLayoutParams( + subtitleView = subtitleView, + width = FrameLayout.LayoutParams.MATCH_PARENT, + height = FrameLayout.LayoutParams.MATCH_PARENT, + leftMargin = 0, + topMargin = 0, + gravity = gravity, + ) + return + } + val videoSize = playerView.player?.videoSize ?: VideoSize.UNKNOWN - val rect = (playerView.contentFrameSubtitleRect() - ?: displayedSubtitleVideoRect( - viewWidth = playerView.width, - viewHeight = playerView.height, - videoWidth = videoSize.width, - videoHeight = videoSize.height, - videoPixelWidthHeightRatio = videoSize.pixelWidthHeightRatio, - resizeMode = playerView.resizeMode, - )).insetByLetterbox(letterbox).insetByTitleSafe(titleSafeFraction) + val displayedVideoRect = displayedSubtitleVideoRect( + viewWidth = playerView.width, + viewHeight = playerView.height, + videoWidth = videoSize.width, + videoHeight = videoSize.height, + videoPixelWidthHeightRatio = videoSize.pixelWidthHeightRatio, + resizeMode = resizeMode, + ) + val rect = selectSubtitleCanvasRect( + resizeMode = resizeMode, + contentFrameRect = playerView.contentFrameSubtitleRect(), + displayedVideoRect = displayedVideoRect, + ).insetByLetterbox(letterbox).insetByTitleSafe(titleSafeFraction) val current = subtitleView.layoutParams as? FrameLayout.LayoutParams val params = current ?: FrameLayout.LayoutParams(rect.width, rect.height) - val gravity = Gravity.TOP or Gravity.START if ( current == null || params.width != rect.width || @@ -686,15 +860,62 @@ private class SubtitleVideoRectSync(playerView: PlayerView) : } } + private fun applyLayoutParams( + subtitleView: View, + width: Int, + height: Int, + leftMargin: Int, + topMargin: Int, + gravity: Int, + ) { + val current = subtitleView.layoutParams as? FrameLayout.LayoutParams + val params = current ?: FrameLayout.LayoutParams(width, height) + if ( + current == null || + params.width != width || + params.height != height || + params.leftMargin != leftMargin || + params.topMargin != topMargin || + params.gravity != gravity + ) { + params.width = width + params.height = height + params.leftMargin = leftMargin + params.topMargin = topMargin + params.gravity = gravity + subtitleView.layoutParams = params + subtitleView.requestLayout() + } + } + private fun dispose(view: View?) { if (isDisposed) return + val playerView = (view as? PlayerView) ?: playerViewRef.get() + isDisposed = true + reconciliationGeneration++ + clearPendingPostLayoutUpdate() + clearPendingVerification(playerView) observedPlayer?.removeListener(this) observedPlayer = null - val playerView = (view as? PlayerView) ?: playerViewRef.get() playerView?.removeOnLayoutChangeListener(this) playerView?.removeOnAttachStateChangeListener(this) contentFrameRef.get()?.removeOnLayoutChangeListener(this) - isDisposed = true + } + + private fun clearPendingPostLayoutUpdate() { + pendingPreDrawObserver?.let { observer -> + if (observer.isAlive) { + observer.removeOnPreDrawListener(postLayoutUpdate) + } + } + pendingPreDrawObserver = null + } + + private fun clearPendingVerification(playerView: PlayerView?) { + pendingVerification?.let { verification -> + playerView?.removeCallbacks(verification) + } + pendingVerification = null } } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/VideoPlayerMediaSpec.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/VideoPlayerMediaSpec.kt index 1f4452543..68b5bccdd 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/VideoPlayerMediaSpec.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/VideoPlayerMediaSpec.kt @@ -6,9 +6,8 @@ import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo data class VideoPlayerMediaSpec( /** - * Catalog identity of what is playing, carried onto the MediaItem so the - * playback service can resolve per-item preferences (subtitle sync) from - * the player alone rather than needing a side channel from the UI. + * Catalog identity of what is playing, carried onto the MediaItem for + * media-session identity and playback diagnostics. */ val contentId: String? = null, val streamUrl: String, diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStore.kt index dbb2db377..2aa8c52cc 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStore.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStore.kt @@ -9,10 +9,15 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStoreFile -import org.prairieserver.prairie.model.settings.EffectiveSetting import org.prairieserver.prairie.model.download.DownloadQuality +import org.prairieserver.prairie.model.settings.EffectiveSettingValue +import org.prairieserver.prairie.model.settings.LanguageOptions import org.prairieserver.prairie.model.settings.PlaybackSettingsKeys +import org.prairieserver.prairie.model.settings.QualityPresets +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.model.settings.SettingScope import org.prairieserver.prairie.model.settings.SubtitleAppearance +import org.prairieserver.prairie.model.settings.SubtitleAppearanceProjection import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.repository.SettingsRepository import kotlinx.coroutines.CoroutineScope @@ -26,6 +31,13 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull @OptIn(ExperimentalCoroutinesApi::class) class AndroidPlayerSettingsStore( @@ -69,6 +81,14 @@ class AndroidPlayerSettingsStore( } val migrationSentinel: String = if (keyPrefix.isEmpty()) MIGRATION_SENTINEL_LEGACY else "migration_v2_$keyPrefix" + + /** + * Separate from [migrationSentinel] deliberately: that one is already + * marked on every install that has run a scoped build, so a rename + * pass gated on it would never run for the devices holding the + * orphaned pre-rename values. + */ + val renameSentinel: String = "migration_rename_v1_$keyPrefix" } // Re-derive scope on every (profile or server) change. @@ -95,6 +115,11 @@ class AndroidPlayerSettingsStore( } private suspend fun ensureMigrated(scope: Scope, store: DataStore) { + migrateLegacyCache(scope, store) + migrateRenamedKeys(scope, store) + } + + private suspend fun migrateLegacyCache(scope: Scope, store: DataStore) { val token = scope.profileId + "/" + scope.migrationSentinel if (synchronized(migrationDone) { token in migrationDone }) return val sentinelKey = booleanPreferencesKey(scope.migrationSentinel) @@ -114,6 +139,75 @@ class AndroidPlayerSettingsStore( synchronized(migrationDone) { migrationDone.add(token) } } + /** + * Copies the two slots the settings cutover renamed + * ([PlaybackSettingsKeys.RenamedLocalKeys]) into their current names. + * + * Carries its own sentinel rather than riding [migrateLegacyCache]'s: that + * one is already marked on every device that has run this app since the + * scoped-store change, so a pass gated on it would never execute for the + * installs that actually hold the orphaned values. Both reads are + * local-first — subtitle appearance drives downloaded playback with no + * server in the loop — so skipping the copy silently reverts a preference + * the user set until a canonical refresh happens to land. + */ + private suspend fun migrateRenamedKeys(scope: Scope, store: DataStore) { + val token = scope.profileId + "/" + scope.renameSentinel + if (synchronized(migrationDone) { token in migrationDone }) return + val sentinelKey = booleanPreferencesKey(scope.renameSentinel) + val current = store.data.first() + if (current[sentinelKey] != true) { + store.edit { prefs -> + for ((oldKey, newKey) in PlaybackSettingsKeys.RenamedLocalKeys) { + copyRenamedSlot(prefs, scope, oldKey = oldKey, newKey = newKey) + } + prefs[sentinelKey] = true + } + } + synchronized(migrationDone) { migrationDone.add(token) } + } + + /** + * Copies one renamed slot, typed by the *new* key's contract type. + * + * The old slot may sit under this scope's prefix (written after the scoped + * store landed) or unprefixed (written before it), so both are checked — + * the same order [scopedRead] uses. A value already present under the new + * name always wins: it is either a fresh edit or a canonical refresh, and + * either outranks whatever the pre-rename build left behind. + */ + private fun copyRenamedSlot( + prefs: androidx.datastore.preferences.core.MutablePreferences, + scope: Scope, + oldKey: String, + newKey: String, + ) { + val target = scope.keyPrefix + newKey + when { + isIntKey(newKey) -> { + if (prefs[intPreferencesKey(target)] != null) return + val legacy = prefs[intPreferencesKey(scope.keyPrefix + oldKey)] + ?: prefs[intPreferencesKey(oldKey)] + ?: return + prefs[intPreferencesKey(target)] = legacy + } + isBooleanKey(newKey) -> { + if (prefs[booleanPreferencesKey(target)] != null) return + val legacy = prefs[booleanPreferencesKey(scope.keyPrefix + oldKey)] + ?: prefs[booleanPreferencesKey(oldKey)] + ?: return + prefs[booleanPreferencesKey(target)] = legacy + } + else -> { + if (prefs[stringPreferencesKey(target)] != null) return + val legacy = prefs[stringPreferencesKey(scope.keyPrefix + oldKey)] + ?: prefs[stringPreferencesKey(oldKey)] + ?: return + prefs[stringPreferencesKey(target)] = legacy + } + } + } + private fun profileScopedFlow(default: T, read: (Preferences, Scope) -> T): Flow = currentScopeFlow.flatMapLatest { scope -> if (scope == null) { @@ -186,18 +280,6 @@ class AndroidPlayerSettingsStore( override val subtitleSyncMsFlow: Flow = profileScopedFlow(0) { p, s -> p.intFor(s, PlaybackSettingsKeys.SubtitleSyncMs, 0) } - override fun subtitleSyncMsFor(contentId: String?): Flow = - if (contentId.isNullOrBlank()) { - subtitleSyncMsFlow - } else { - profileScopedFlow(0) { p, s -> - decodeSubtitleSyncOverrides( - p.stringFor(s, PlaybackSettingsKeys.SubtitleSyncMsByItem, ""), - )[contentId] - ?: p.intFor(s, PlaybackSettingsKeys.SubtitleSyncMs, 0) - } - } - override val nextUpPromptSecondsFlow: Flow = profileScopedFlow(30) { p, s -> p.intFor(s, PlaybackSettingsKeys.NextUpPromptSeconds, 30) } @@ -214,12 +296,35 @@ class AndroidPlayerSettingsStore( p.intFor(s, PlaybackSettingsKeys.PassOutThreshold, DEFAULT_PASSOUT_THRESHOLD) } + // Uncapped is spelled 0 locally (Preferences cannot hold a null) and + // translated to JSON null on the wire — 0 is outside the contract's + // 100..200000 range, so it can never collide with a real cap. + override val maxBitrateKbpsFlow: Flow = + profileScopedFlow(null) { p, s -> + p.intFor(s, PlaybackSettingsKeys.MaxBitrateKbps, 0).takeIf { it > 0 } + } + // ---- Strings ------------------------------------------------------- + // Legacy compound spellings ("1080p-high") are normalized on read: the + // bitrate they encoded lives on its own axis now, and handing a compound + // value to the player or back to the server would be refused. override val preferredQualityFlow: Flow = - profileScopedFlow("auto") { p, s -> p.stringFor(s, PlaybackSettingsKeys.PreferredQuality, "auto") } + profileScopedFlow(QualityPresets.RESOLUTION_AUTO) { p, s -> + QualityPresets.normalizeResolution( + p.stringFor(s, PlaybackSettingsKeys.PreferredQuality, QualityPresets.RESOLUTION_AUTO), + ) + } + // Older builds stored the display name ("English") here rather than a BCP 47 + // tag. Those values are rejected by the server and never matched a track, so + // they are translated on read instead of being handed to ExoPlayer or + // re-sent. Anything already a tag passes through untouched. override val audioLanguageFlow: Flow = - profileScopedFlow("") { p, s -> p.stringFor(s, PlaybackSettingsKeys.AudioLanguage, "") } + profileScopedFlow("") { p, s -> + LanguageOptions.migrateLegacyValue( + p.stringFor(s, PlaybackSettingsKeys.AudioLanguage, ""), + ) + } override val videoGravityFlow: Flow = profileScopedFlow("fit") { p, s -> p.stringFor(s, PlaybackSettingsKeys.VideoGravity, "fit") } @@ -227,17 +332,18 @@ class AndroidPlayerSettingsStore( override val orientationModeFlow: Flow = profileScopedFlow("auto") { p, s -> p.stringFor(s, PlaybackSettingsKeys.OrientationMode, "auto") } + // The composite is the stored truth, but the granular subtitle.* slots are + // a live overlay: the player's per-field controls write them directly, and + // a value there that the composite has not caught up with is the newer + // edit. Projecting on read (and again on flush) is what keeps a per-field + // change from being invisible to the server. override val subtitleAppearanceFlow: Flow = - profileScopedFlow(SubtitleAppearance.DEFAULT) { p, s -> - SubtitleAppearance.decode(p.stringFor(s, PlaybackSettingsKeys.SubtitleAppearance, "")) - } + profileScopedFlow(SubtitleAppearance.DEFAULT) { p, s -> p.projectedAppearance(s) } override val savedCustomSubtitleAppearanceFlow: Flow = profileScopedFlow(SubtitleAppearance.DEFAULT) { p, s -> - SubtitleAppearance.decode( - p[stringPreferencesKey(s.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] - ?: p.stringFor(s, PlaybackSettingsKeys.SubtitleAppearance, ""), - ) + val saved = p[stringPreferencesKey(s.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] + if (saved != null) SubtitleAppearance.decode(saved) else p.projectedAppearance(s) } override val subtitleMatchesDeviceFlow: Flow = @@ -301,7 +407,12 @@ class AndroidPlayerSettingsStore( val clamped = value.coerceIn(0.25, 4.0) withScope { scope, store -> store.edit { it[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.PlaybackSpeed)] = clamped.toString() } - serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.PlaybackSpeed, clamped.toString()) + serverSettingsFlusher.enqueue( + scope.profileId, + PlaybackSettingsKeys.PlaybackSpeed, + clamped.toString(), + scope.serverUrl, + ) } } @@ -311,28 +422,6 @@ class AndroidPlayerSettingsStore( override suspend fun setSubtitleSyncMs(value: Int) = writeInt(PlaybackSettingsKeys.SubtitleSyncMs, value.coerceIn(-10000, 10000)) - override suspend fun setSubtitleSyncMsFor(contentId: String, value: Int) { - if (contentId.isBlank()) return - val clamped = value.coerceIn(-10000, 10000) - withScope { scope, store -> - store.edit { prefs -> - val globalKey = intPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleSyncMs) - val mapKey = stringPreferencesKey( - scope.keyPrefix + PlaybackSettingsKeys.SubtitleSyncMsByItem, - ) - val global = prefs[globalKey] ?: 0 - val current = decodeSubtitleSyncOverrides(prefs[mapKey].orEmpty()) - val next = LinkedHashMap(current).apply { - remove(contentId) - // Matching the profile default needs no override; dropping it - // keeps the map from filling with no-op entries. - if (clamped != global) put(contentId, clamped) - } - prefs[mapKey] = encodeSubtitleSyncOverrides(next) - } - } - } - override suspend fun setNextUpPromptSeconds(value: Int) = writeInt(PlaybackSettingsKeys.NextUpPromptSeconds, value.coerceIn(0, 120)) @@ -346,7 +435,32 @@ class AndroidPlayerSettingsStore( writeInt(PlaybackSettingsKeys.SleepTimerDefaultMinutes, value.coerceIn(0, 240)) override suspend fun setPreferredQuality(value: String) = - writeString(PlaybackSettingsKeys.PreferredQuality, value) + writeString(PlaybackSettingsKeys.PreferredQuality, QualityPresets.normalizeResolution(value)) + + override suspend fun setQuality(resolution: String, bitrateKbps: Int?) { + val normalized = QualityPresets.normalizeResolution(resolution) + // 0 is the local spelling of uncapped; the flusher turns it into the + // JSON null the contract means by "no cap". + val capped = bitrateKbps?.takeIf { it > 0 }?.coerceIn(MIN_BITRATE_KBPS, MAX_BITRATE_KBPS) ?: 0 + withScope { scope, store -> + store.edit { + it[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.PreferredQuality)] = normalized + it[intPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.MaxBitrateKbps)] = capped + } + serverSettingsFlusher.enqueue( + scope.profileId, + PlaybackSettingsKeys.PreferredQuality, + normalized, + scope.serverUrl, + ) + serverSettingsFlusher.enqueue( + scope.profileId, + PlaybackSettingsKeys.MaxBitrateKbps, + capped.toString(), + scope.serverUrl, + ) + } + } override suspend fun setAudioLanguage(value: String) = writeString(PlaybackSettingsKeys.AudioLanguage, value) @@ -360,16 +474,54 @@ class AndroidPlayerSettingsStore( writeString(PlaybackSettingsKeys.OrientationMode, value) override suspend fun setSubtitleAppearance(value: SubtitleAppearance) { - val json = value.sanitized().toJsonString() + val sanitized = value.sanitized() + val json = sanitized.toJsonString() withScope { scope, store -> - store.edit { - it[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleAppearance)] = json - it[stringPreferencesKey(scope.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] = json + store.edit { prefs -> + prefs[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleAppearance)] = json + prefs[stringPreferencesKey(scope.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] = json + // The granular slots are rewritten from the composite rather + // than left behind: they are read back as an overlay, so a + // stale field would resurrect the value the user just replaced. + writeGranularAppearance(prefs, scope, sanitized) // Setting an explicit appearance implicitly enables the // device override (matches iOS `setSubtitleAppearance`). - it[booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleUsesDeviceOverride)] = true + prefs[booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleUsesDeviceOverride)] = true + } + serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json, scope.serverUrl) + } + } + + /** + * Flushes the granular `subtitle.*` slots into the composite and enqueues + * it, so a per-field edit made anywhere (the player HUD, a TV picker) + * reaches the server as `playback.subtitle_appearance`. + * + * Callable on its own because the granular fields have no key of their own + * on the wire: without this projection a per-field write is device-local + * forever, which is the drift this exists to close. + */ + override suspend fun flushProjectedSubtitleAppearance() { + withScope { scope, store -> + val snapshot = store.data.first() + val projected = snapshot.projectedAppearance(scope) + val json = projected.toJsonString() + if (snapshot.stringFor(scope, PlaybackSettingsKeys.SubtitleAppearance, "") == json) return@withScope + store.edit { prefs -> + prefs[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleAppearance)] = json + prefs[stringPreferencesKey(scope.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] = json } - serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json) + serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json, scope.serverUrl) + } + } + + private fun writeGranularAppearance( + prefs: androidx.datastore.preferences.core.MutablePreferences, + scope: Scope, + appearance: SubtitleAppearance, + ) { + for ((key, raw) in SubtitleAppearanceProjection.flatten(appearance)) { + writeRawString(prefs, scope, key, raw) } } @@ -378,7 +530,9 @@ class AndroidPlayerSettingsStore( override suspend fun refreshFromServer() { val repo = settingsRepository ?: return withScope { scope, store -> - val result = repo.getEffectiveSettings(PlaybackSettingsKeys.DeviceSettings) + // Batched canonical resolution: one request answers every + // device-relevant key, each with the scope it resolved from. + val result = repo.getEffectiveValues(RemoteDeviceSettings) if (result !is ApiResult.Success) return@withScope applyEffectiveLocally(scope, store, result.data) } @@ -395,13 +549,19 @@ class AndroidPlayerSettingsStore( snapshot[stringPreferencesKey(scope.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] ?: snapshot.stringFor(scope, PlaybackSettingsKeys.SubtitleAppearance, "") ) - val json = appearance.sanitized().toJsonString() + val sanitized = appearance.sanitized() + val json = sanitized.toJsonString() store.edit { it[booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleUsesDeviceOverride)] = true it[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleAppearance)] = json it[stringPreferencesKey(scope.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] = json + // The granular slots overlay the composite on read, so + // restoring the saved appearance has to restore them too — + // otherwise the fields left by whatever resolved while the + // override was off win right back over it. + writeGranularAppearance(it, scope, sanitized) } - serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json) + serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json, scope.serverUrl) serverSettingsFlusher.flushNow() } else { store.edit { @@ -413,7 +573,7 @@ class AndroidPlayerSettingsStore( } it[booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleUsesDeviceOverride)] = false } - serverSettingsFlusher.enqueueDelete(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance) + serverSettingsFlusher.enqueueDelete(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, scope.serverUrl) serverSettingsFlusher.flushNow() refreshFromServer() } @@ -422,7 +582,7 @@ class AndroidPlayerSettingsStore( override suspend fun resetDeviceSetting(key: String) { withScope { scope, _ -> - serverSettingsFlusher.enqueueDelete(scope.profileId, key) + serverSettingsFlusher.enqueueDelete(scope.profileId, key, scope.serverUrl) serverSettingsFlusher.flushNow() refreshFromServer() } @@ -430,8 +590,10 @@ class AndroidPlayerSettingsStore( override suspend fun resetAllDeviceSettings() { withScope { scope, store -> - for (key in PlaybackSettingsKeys.DeviceSettings) { - serverSettingsFlusher.enqueueDelete(scope.profileId, key) + // Only the server-stored keys have rows to delete; the granular + // subtitle.* fields live inside playback.subtitle_appearance. + for (key in RemoteDeviceSettings) { + serverSettingsFlusher.enqueueDelete(scope.profileId, key, scope.serverUrl) } store.edit { it[booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleUsesDeviceOverride)] = false @@ -448,26 +610,84 @@ class AndroidPlayerSettingsStore( private suspend fun applyEffectiveLocally( scope: Scope, store: DataStore, - effective: Map, + effective: Map, ) { store.edit { prefs -> - for (key in PlaybackSettingsKeys.DeviceSettings) { + for (key in RemoteDeviceSettings) { + // The canonical endpoint answers every known key, including + // ones with nothing stored anywhere — those come back with + // source "default" and the contract default as the value, so + // writing each entry hydrates defaults from the contract + // rather than from anything hardcoded here. A key absent + // from the response is one this server's contract does not + // know (older revision); the local value is kept rather than + // guessed at. val entry = effective[key] ?: continue - writeRawString(prefs, scope, key, entry.effectiveValue) + writeJsonValue(prefs, scope, key, entry.value) } - // Clear the override flag when the server reports no - // device-scoped subtitle_appearance — otherwise a previous - // session's flag could survive a server-side reset. + // The override flag mirrors where the subtitle appearance + // actually resolved from. Clearing it when the value no longer + // comes from this device keeps a previous session's flag from + // surviving a server-side reset. val subtitleEntry = effective[PlaybackSettingsKeys.SubtitleAppearance] + val hasDeviceOverride = subtitleEntry?.scope == SettingScope.PROFILE_DEVICE.wire prefs[booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleUsesDeviceOverride)] = - subtitleEntry?.hasDeviceOverride ?: false - if (subtitleEntry?.hasDeviceOverride == true) { - prefs[stringPreferencesKey(scope.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] = - subtitleEntry.effectiveValue + hasDeviceOverride + if (subtitleEntry != null) { + // The granular slots are an overlay on the composite, so a + // resolved appearance has to be flattened back into them. Left + // alone, the previous device's field values would win over the + // value the server just said applies. + writeGranularAppearance( + prefs, + scope, + SubtitleAppearance.decode(subtitleEntry.value.toString()), + ) + if (hasDeviceOverride) { + prefs[stringPreferencesKey(scope.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] = + subtitleEntry.value.toString() + } } } } + /** + * Writes one typed JSON value from the canonical effective response into + * the local slot each flow reads: booleans and ints natively, doubles as + * their string spelling, objects as their JSON document, JSON null as the + * empty string (the local spelling of "no preference"), and everything + * else as the primitive's content. + */ + private fun writeJsonValue( + prefs: androidx.datastore.preferences.core.MutablePreferences, + scope: Scope, + key: String, + value: JsonElement, + ) { + val scopedName = scope.keyPrefix + key + when { + isBooleanKey(key) -> (value as? JsonPrimitive)?.booleanOrNull?.let { + prefs[booleanPreferencesKey(scopedName)] = it + } + isIntKey(key) -> { + // A nullable int (max_bitrate_kbps) resolving to null means + // "no cap", which the store spells as 0. Skipping the write + // instead would leave a previous cap in place and quietly keep + // throttling playback the server just said to stop throttling. + val resolved = (value as? JsonPrimitive)?.intOrNull + ?: if (value is JsonNull && key in NULLABLE_INT_SETTINGS) 0 else null + resolved?.let { prefs[intPreferencesKey(scopedName)] = it } + } + isDoubleKey(key) -> (value as? JsonPrimitive)?.doubleOrNull?.let { + prefs[stringPreferencesKey(scopedName)] = it.toString() + } + value is JsonNull -> prefs[stringPreferencesKey(scopedName)] = "" + value is JsonObject -> prefs[stringPreferencesKey(scopedName)] = value.toString() + value is JsonPrimitive -> prefs[stringPreferencesKey(scopedName)] = value.content + else -> prefs[stringPreferencesKey(scopedName)] = value.toString() + } + } + private fun writeRawString(prefs: androidx.datastore.preferences.core.MutablePreferences, scope: Scope, key: String, raw: String) { val scopedName = scope.keyPrefix + key when { @@ -487,14 +707,14 @@ class AndroidPlayerSettingsStore( private suspend fun writeBool(key: String, value: Boolean) { withScope { scope, store -> store.edit { it[booleanPreferencesKey(scope.keyPrefix + key)] = value } - serverSettingsFlusher.enqueue(scope.profileId, key, value.toString()) + serverSettingsFlusher.enqueue(scope.profileId, key, value.toString(), scope.serverUrl) } } private suspend fun writeInt(key: String, value: Int) { withScope { scope, store -> store.edit { it[intPreferencesKey(scope.keyPrefix + key)] = value } - serverSettingsFlusher.enqueue(scope.profileId, key, value.toString()) + serverSettingsFlusher.enqueue(scope.profileId, key, value.toString(), scope.serverUrl) } } @@ -525,7 +745,7 @@ class AndroidPlayerSettingsStore( private suspend fun writeString(key: String, value: String) { withScope { scope, store -> store.edit { it[stringPreferencesKey(scope.keyPrefix + key)] = value } - serverSettingsFlusher.enqueue(scope.profileId, key, value) + serverSettingsFlusher.enqueue(scope.profileId, key, value, scope.serverUrl) } } @@ -564,6 +784,34 @@ class AndroidPlayerSettingsStore( private fun Preferences.stringFor(scope: Scope, baseKey: String, default: String): String = scopedRead(scope, baseKey, default, ::stringPreferencesKey) + /** + * The composite appearance with the granular, client-local `subtitle.*` + * slots merged over it. + * + * The contract has no definitions for the granular fields, so they never + * leave the device on their own — but a per-field edit still has to reach + * the server, and this is where the two representations are reconciled. + * Merging is sparse (an absent or unparseable field leaves the composite's + * value alone), matching the schema's own rule for a stored appearance. + */ + private fun Preferences.projectedAppearance(scope: Scope): SubtitleAppearance { + val base = SubtitleAppearance.decode( + stringFor(scope, PlaybackSettingsKeys.SubtitleAppearance, ""), + ) + val granular = SubtitleAppearanceProjection.GRANULAR_KEYS.associateWith { key -> + when { + isBooleanKey(key) -> + this[booleanPreferencesKey(scope.keyPrefix + key)]?.toString() + ?: this[booleanPreferencesKey(key)]?.toString() + isIntKey(key) -> + this[intPreferencesKey(scope.keyPrefix + key)]?.toString() + ?: this[intPreferencesKey(key)]?.toString() + else -> stringFor(scope, key, "").takeIf { it.isNotBlank() } + } + } + return SubtitleAppearanceProjection.project(granular, base) + } + private companion object { const val SAVED_CUSTOM_SUBTITLE_APPEARANCE = "subtitle_appearance.saved_custom" const val MIGRATION_SENTINEL_LEGACY = "migration_v1" @@ -572,37 +820,50 @@ class AndroidPlayerSettingsStore( // the previous hardcoded AutoPlayGuard threshold of 3). const val DEFAULT_RESUME_REWIND_SECONDS = 7 const val DEFAULT_PASSOUT_THRESHOLD = 3 + // The contract's playback.max_bitrate_kbps bounds; a value outside + // them is rejected as invalid_value and the flush would be dropped. + const val MIN_BITRATE_KBPS = 100 + const val MAX_BITRATE_KBPS = 200_000 val VALID_VIDEO_GRAVITY = setOf("fit", "fill", "stretch") - val BOOLEAN_KEYS: Set = setOf( - PlaybackSettingsKeys.AutoSkipIntro, - PlaybackSettingsKeys.AutoSkipCredits, - PlaybackSettingsKeys.AutoPlayNext, - PlaybackSettingsKeys.HdrEnabled, - PlaybackSettingsKeys.DvProfile7HDR10Fallback, - PlaybackSettingsKeys.DolbyVisionEnabled, - PlaybackSettingsKeys.MatchContentFrameRate, - PlaybackSettingsKeys.SubtitleTextOutline, - ) + // Type classification comes from the generated contract rather than a + // hand-kept list. This used to be a second table that had to agree with + // PlaybackSettingsKeys.DeviceSettings by discipline alone; a key added + // to one and missed in the other would flush as the wrong type and be + // silently dropped on read. + // + // The extras below are the granular subtitle appearance fields Android + // flattens locally. The contract carries them as one composite object + // (playback.subtitle_appearance), so they have no generated entry and + // are listed here as the local-only values they are. + /** + * The device-relevant keys the canonical batched endpoint can + * answer: the store's device set minus the granular subtitle.* + * fields Android flattens locally (the contract carries those as + * the one composite playback.subtitle_appearance object, so the + * resolver has no definitions for them). + */ + val RemoteDeviceSettings: List = SettingKeys.REMOTE.toSet().let { remote -> + PlaybackSettingsKeys.DeviceSettings.filter { it in remote } + } - val INT_KEYS: Set = setOf( - PlaybackSettingsKeys.AudioSyncMs, - PlaybackSettingsKeys.SubtitleSyncMs, - PlaybackSettingsKeys.NextUpPromptSeconds, - PlaybackSettingsKeys.SleepTimerDefaultMinutes, - PlaybackSettingsKeys.SubtitleBackgroundOpacity, - ) + val BOOLEAN_KEYS: Set = SettingKeys.BOOLEAN_KEYS + + setOf(PlaybackSettingsKeys.SubtitleTextOutline) - val DOUBLE_KEYS: Set = setOf( - PlaybackSettingsKeys.PlaybackSpeed, - ) + val INT_KEYS: Set = SettingKeys.INT_KEYS + + setOf(PlaybackSettingsKeys.SubtitleBackgroundOpacity) + + val DOUBLE_KEYS: Set = SettingKeys.DOUBLE_KEYS + + /** Int keys whose contract null means "no cap", stored locally as 0. */ + val NULLABLE_INT_SETTINGS: Set = setOf(SettingKeys.PLAYBACK_MAX_BITRATE_KBPS) fun isBooleanKey(key: String): Boolean = key in BOOLEAN_KEYS fun isIntKey(key: String): Boolean = key in INT_KEYS fun isDoubleKey(key: String): Boolean = key in DOUBLE_KEYS fun fileNameFor(profileId: String): String = - "prairie_player_settings_${profileHash(profileId)}" + "silo_player_settings_${profileHash(profileId)}" fun profileHash(profileId: String): String = sha256Hex(profileId).take(16) @@ -613,33 +874,3 @@ class AndroidPlayerSettingsStore( .joinToString(separator = "") { "%02x".format(it) } } } - -/** - * `contentId=ms` pairs separated by newlines. Deliberately not JSON: the values - * are a string id and an int, and this store already speaks plain preference - * strings, so a serializer dependency here would buy nothing. - * - * Ids containing the separators are dropped rather than escaped — no catalog id - * looks like that, and silently corrupting a neighbouring entry would be worse - * than losing an override the user can set again. - */ -internal fun decodeSubtitleSyncOverrides(raw: String): Map { - if (raw.isBlank()) return emptyMap() - val out = LinkedHashMap() - for (line in raw.lineSequence()) { - val id = line.substringBefore('=', "").trim() - val ms = line.substringAfter('=', "").trim().toIntOrNull() - if (id.isNotEmpty() && ms != null) out[id] = ms - } - return out -} - -internal fun encodeSubtitleSyncOverrides(overrides: Map): String = - overrides.entries - .filter { (id, _) -> id.isNotBlank() && '=' !in id && '\n' !in id } - // Bounded so a long viewing history cannot grow this preference without - // limit; the most recently written entries are the ones worth keeping. - .takeLast(MAX_SUBTITLE_SYNC_OVERRIDES) - .joinToString(separator = "\n") { (id, ms) -> "$id=$ms" } - -private const val MAX_SUBTITLE_SYNC_OVERRIDES = 200 diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/OverlayPrefsStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/OverlayPrefsStore.kt index 0e73fc92a..3fe2941a9 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/OverlayPrefsStore.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/OverlayPrefsStore.kt @@ -1,5 +1,7 @@ package org.prairieserver.prairie.common.settings +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.model.settings.SettingScope import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.overlays.CardOverlayPrefs import org.prairieserver.prairie.overlays.OverlaySchema @@ -14,6 +16,9 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull /** * Cached card-overlay configuration for the signed-in profile. Mirrors @@ -21,7 +26,7 @@ import kotlinx.coroutines.sync.withLock * * Resolves a single rendered [CardOverlayPrefs] from one of two sources, * in this priority: - * 1. The user's saved prefs (`GET /settings/card_overlays`) — if + * 1. The user's canonical profile value (`ui.card_overlays`) — if * present, this is the entire source of truth. * 2. Otherwise, the admin-configured baseline JSON from * `GET /settings/overlay-config` (`defaults` field). @@ -29,8 +34,9 @@ import kotlinx.coroutines.sync.withLock * * Winner-take-all, not layered merging — [setPrefs] always saves a full * document (not a diff), keeping the wire format compatible with web, - * iOS, and tvOS. Hydrated lazily on first read and refreshed after every - * save so card views always see the shape they just persisted. + * iOS, and tvOS. Hydrated lazily on first read; successful canonical writes + * become the confirmed local state so card views immediately see the shape + * they just persisted. */ interface OverlayPrefsStore { /** @@ -90,8 +96,35 @@ class DefaultOverlayPrefsStore( @Volatile private var adminDefaultsRaw: String? = null + // The last state confirmed by a successful canonical read or write. An + // optimistic edit rolls back here when its PUT fails, including when the + // network is unavailable and a follow-up refresh would fail as well. + @Volatile + private var confirmedPrefs: CardOverlayPrefs = _prefs.value + + @Volatile + private var confirmedHasUserOverride: Boolean = false + + // Refreshes run on a foreground IO coroutine while edits are drained on + // the application Main scope. Keep their short state commits atomic so a + // response captured before a newer edit cannot replace that edit after it + // succeeds. This is deliberately separate from [writeGeneration], whose + // only job is invalidating queued/in-flight drains at a session boundary. + private val stateLock = Any() + + // Only [clear] crosses an authenticated-session boundary. Resetting the + // profile value must not discard an otherwise valid admin-config response. + private var sessionStateEpoch: Long = 0L + + private var canonicalStateEpoch: Long = 0L + private val refreshLock = Mutex() + // A reset must finish its DELETE before a later edit is staged for PUT. + // The edit still updates [_prefs] synchronously, but its drain waits here + // so the wire order is always DELETE -> PUT and the later edit wins. + private val mutationBoundaryMutex = Mutex() + // Coalesced-write state. `writeMutex` guards the mutable bookkeeping // below; the actual PUT happens inside the drain coroutine. private val writeMutex = Mutex() @@ -117,13 +150,12 @@ class DefaultOverlayPrefsStore( } /** - * Re-fetch both the admin config and the user setting, then recompute + * Re-fetch both the admin config and the canonical profile value, then recompute * [prefs]. * * Failure semantics mirror iOS: - * - A 404 on the user setting means "not set yet" and is treated as - * success — `userRaw` stays null and we render from admin defaults - * or registry defaults. + * - A canonical null/default means "not set yet" and renders from admin + * defaults or registry defaults. * - Any other transport error on either endpoint leaves * [hasHydrated] false so the next [hydrateIfNeeded] retries. This is * critical for the admin kill-switch: if `/overlay-config` errors @@ -132,60 +164,127 @@ class DefaultOverlayPrefsStore( * toggle is silently ignored for the session. */ override suspend fun refresh() = refreshLock.withLock { - _isLoading.value = true - _lastError.value = null + val refreshState = synchronized(stateLock) { + _isLoading.value = true + _lastError.value = null + RefreshState( + sessionStateEpoch = sessionStateEpoch, + canonicalStateEpoch = canonicalStateEpoch, + adminDefaultsRaw = adminDefaultsRaw, + ) + } try { var resolvedEnabled = true var resolvedAdminDefaults: String? = null var configFetchFailed = false + var configError: String? = null when (val config = repository.overlayConfig()) { is ApiResult.Success -> { resolvedEnabled = config.data.enabled resolvedAdminDefaults = config.data.defaults } is ApiResult.Error -> { - _lastError.value = config.message + configError = config.message configFetchFailed = true } is ApiResult.NetworkError -> { - _lastError.value = config.exception.message + configError = config.exception.message configFetchFailed = true } } - var userRaw: String? = null + var userValue: JsonElement? = null var userFetchFailed = false - when (val entry = repository.getSetting(OVERLAY_SETTING_KEY)) { - is ApiResult.Success -> userRaw = entry.data - is ApiResult.Error -> - if (entry.code == 404) { - userRaw = null - } else { - _lastError.value = entry.message + var userError: String? = null + when (val result = repository.getEffectiveValues(listOf(OVERLAY_SETTING_KEY))) { + is ApiResult.Success -> { + val entry = result.data[OVERLAY_SETTING_KEY] + if (entry == null) { + userError = "The server did not resolve $OVERLAY_SETTING_KEY" userFetchFailed = true + } else if ( + entry.source == SettingScope.PROFILE.wire && + entry.value !is JsonNull + ) { + userValue = entry.value } + } + is ApiResult.Error -> { + userError = result.message + userFetchFailed = true + } is ApiResult.NetworkError -> { - _lastError.value = entry.exception.message + userError = result.exception.message userFetchFailed = true } } - // Preserve cached config state on transient failures. The - // sentinel `resolvedEnabled = true` is only valid when the - // fetch actually succeeded. - if (!configFetchFailed) { - _enabled.value = resolvedEnabled - adminDefaultsRaw = resolvedAdminDefaults + val resolvedPrefs = if (userFetchFailed) { + null + } else { + val defaults = if (configFetchFailed) { + refreshState.adminDefaultsRaw + } else { + resolvedAdminDefaults + } + OverlaySchema.parse(userValue?.toString() ?: defaults) } - if (!userFetchFailed) { - hasUserOverride = userRaw != null - val defaults = if (configFetchFailed) adminDefaultsRaw else resolvedAdminDefaults - _prefs.value = OverlaySchema.parse(userRaw ?: defaults) + val resolvedAdminPrefs = if (configFetchFailed) { + null + } else { + OverlaySchema.parse(resolvedAdminDefaults) } - // Only complete hydration when BOTH endpoints gave a - // definitive answer. - if (!configFetchFailed && !userFetchFailed) { - hasHydrated = true + + synchronized(stateLock) { + // Clear crosses a session boundary, so none of the old refresh + // may land. Profile edits and reset only invalidate the + // user-derived half: the independent admin kill-switch and + // baseline remain valid and must still update. + if (sessionStateEpoch == refreshState.sessionStateEpoch) { + val userResponseIsCurrent = + canonicalStateEpoch == refreshState.canonicalStateEpoch + // Preserve cached config state on transient failures. The + // sentinel `resolvedEnabled = true` is only valid when the + // fetch actually succeeded. + if (!configFetchFailed) { + _enabled.value = resolvedEnabled + adminDefaultsRaw = resolvedAdminDefaults + // When the current confirmed state has no user value, + // the admin baseline is independently authoritative. + // Keep an optimistic edit visible, but update its + // rollback target so a failed PUT cannot restore an + // obsolete baseline. + if ( + (!userResponseIsCurrent || userFetchFailed) && + !confirmedHasUserOverride && + resolvedAdminPrefs != null + ) { + val wasShowingConfirmedState = + _prefs.value == confirmedPrefs && + hasUserOverride == confirmedHasUserOverride + confirmedPrefs = resolvedAdminPrefs + if (wasShowingConfirmedState) { + _prefs.value = resolvedAdminPrefs + } + } + } + + if (userResponseIsCurrent) { + _lastError.value = userError ?: configError + if (!userFetchFailed && resolvedPrefs != null) { + val hasOverride = userValue != null + hasUserOverride = hasOverride + _prefs.value = resolvedPrefs + confirmedHasUserOverride = hasOverride + confirmedPrefs = resolvedPrefs + } + // Only complete hydration when BOTH endpoints gave a + // definitive answer. + if (!configFetchFailed && !userFetchFailed) { + hasHydrated = true + } + } + } } } finally { _isLoading.value = false @@ -200,14 +299,34 @@ class DefaultOverlayPrefsStore( * lands after a faster later one. */ override fun setPrefs(next: CardOverlayPrefs) { - _prefs.value = next - hasUserOverride = true + val generation = synchronized(stateLock) { + canonicalStateEpoch += 1 + _prefs.value = next + hasUserOverride = true + writeGeneration + } scope.launch { - writeMutex.withLock { - pendingSnapshot = next - if (pendingWrite?.isActive != true) { - val generation = writeGeneration - pendingWrite = scope.launch { flushPendingWrites(generation) } + mutationBoundaryMutex.withLock { + writeMutex.withLock { + val belongsToCurrentSession = synchronized(stateLock) { + if (writeGeneration != generation) { + false + } else { + // Re-assert the optimistic state while staging the + // snapshot. A preceding write may have completed + // between the immediate UI update above and this + // coroutine acquiring the mutex. + _prefs.value = next + hasUserOverride = true + true + } + } + if (!belongsToCurrentSession) return@withLock + + pendingSnapshot = next + if (pendingWrite?.isActive != true) { + pendingWrite = scope.launch { flushPendingWrites(generation) } + } } } } @@ -233,21 +352,64 @@ class DefaultOverlayPrefsStore( // for the cleared session reaches the wire. if (currentCoroutineContext()[Job]?.isActive != true) return if (writeGeneration != generation) return - val json = OverlaySchema.serialize(snapshot) + val json = Json.parseToJsonElement(OverlaySchema.serialize(snapshot)) if (currentCoroutineContext()[Job]?.isActive != true) return if (writeGeneration != generation) return - when (val result = repository.setSetting(OVERLAY_SETTING_KEY, json)) { - is ApiResult.Success -> Unit + when (val result = repository.setProfileValue(OVERLAY_SETTING_KEY, json)) { + is ApiResult.Success -> { + val applied = writeMutex.withLock { + synchronized(stateLock) { + if (writeGeneration != generation) { + false + } else { + // Every confirmed snapshot invalidates a GET + // that was captured before this PUT completed, + // even when a newer snapshot is already queued. + canonicalStateEpoch += 1 + confirmedPrefs = snapshot + confirmedHasUserOverride = true + // Do not paint an older successful snapshot + // over a newer edit that is already queued. + if (pendingSnapshot == null) { + _prefs.value = snapshot + hasUserOverride = true + _lastError.value = null + } + true + } + } + } + if (!applied) return + } is ApiResult.Error -> { if (writeGeneration != generation) return - _lastError.value = result.message - refresh() + reconcileFailedWrite(result.message, generation) } is ApiResult.NetworkError -> { if (writeGeneration != generation) return - _lastError.value = result.exception.message - refresh() + reconcileFailedWrite(result.exception.message, generation) + } + } + } + } + + private suspend fun reconcileFailedWrite(message: String?, generation: Int) { + writeMutex.withLock { + synchronized(stateLock) { + if (writeGeneration != generation) return@synchronized + // A refresh may have started after the optimistic edit but + // before this terminal failure. Make that response stale so + // it cannot erase the rejection or re-confirm old state. + canonicalStateEpoch += 1 + // If another edit is queued, it is still the optimistic state + // the user should see. Otherwise restore the last + // server-confirmed document instead of leaving a rejected + // value on screen. + if (pendingSnapshot == null) { + _prefs.value = confirmedPrefs + hasUserOverride = confirmedHasUserOverride } + _lastError.value = message } } } @@ -258,28 +420,78 @@ class DefaultOverlayPrefsStore( * a slower earlier PUT can't land server-side after the DELETE and * recreate the document the user just asked us to drop. */ - override suspend fun resetToDefaults() { + override suspend fun resetToDefaults() = mutationBoundaryMutex.withLock { // Bump first so any drain that's mid-flight (already past its snapshot // grab) sees the generation change and bails before its PUT lands. - writeGeneration += 1 - writeMutex.withLock { + val resetState = synchronized(stateLock) { + writeGeneration += 1 + canonicalStateEpoch += 1 + ResetState( + writeGeneration = writeGeneration, + canonicalStateEpoch = canonicalStateEpoch, + ) + } + val inflight = writeMutex.withLock { pendingSnapshot = null - pendingWrite?.cancel() + pendingWrite.also { it?.cancel() } } - pendingWrite?.join() - writeMutex.withLock { pendingWrite = null } - - when (val result = repository.deleteSetting(OVERLAY_SETTING_KEY)) { - is ApiResult.Success -> hasUserOverride = false - is ApiResult.Error -> - if (result.code == 404) { - hasUserOverride = false - } else { - _lastError.value = result.message + inflight?.join() + writeMutex.withLock { + if (pendingWrite === inflight) pendingWrite = null + } + + when (val result = repository.clearProfileValue(OVERLAY_SETTING_KEY)) { + is ApiResult.Success -> { + val shouldRefresh = synchronized(stateLock) { + if (writeGeneration != resetState.writeGeneration) { + false + } else { + val isStillLatestMutation = + canonicalStateEpoch == resetState.canonicalStateEpoch + canonicalStateEpoch += 1 + // The DELETE is canonical even when a newer optimistic + // edit is waiting. If that later PUT fails, it must + // roll back to the now-cleared server state, not the + // override that the DELETE removed. + val fallback = OverlaySchema.parse(adminDefaultsRaw) + confirmedHasUserOverride = false + confirmedPrefs = fallback + if (isStillLatestMutation) { + hasUserOverride = false + _prefs.value = fallback + } + isStillLatestMutation + } + } + if (shouldRefresh) refresh() + } + is ApiResult.Error -> { + synchronized(stateLock) { + if (writeGeneration == resetState.writeGeneration) { + val isStillLatestMutation = + canonicalStateEpoch == resetState.canonicalStateEpoch + canonicalStateEpoch += 1 + if (!isStillLatestMutation) return@synchronized + _prefs.value = confirmedPrefs + hasUserOverride = confirmedHasUserOverride + _lastError.value = result.message + } + } + } + is ApiResult.NetworkError -> { + synchronized(stateLock) { + if (writeGeneration == resetState.writeGeneration) { + val isStillLatestMutation = + canonicalStateEpoch == resetState.canonicalStateEpoch + canonicalStateEpoch += 1 + if (!isStillLatestMutation) return@synchronized + _prefs.value = confirmedPrefs + hasUserOverride = confirmedHasUserOverride + _lastError.value = result.exception.message + } } - is ApiResult.NetworkError -> _lastError.value = result.exception.message + } } - refresh() } override fun clear() { @@ -292,20 +504,42 @@ class DefaultOverlayPrefsStore( // and exits. Cancelling the Job + nulling `pendingWrite` still happens // under the mutex on the write coroutine, but correctness no longer // depends on that running before the session boundary. - writeGeneration += 1 - pendingSnapshot = null - val inflight = pendingWrite - pendingWrite = null + val inflight = synchronized(stateLock) { + writeGeneration += 1 + sessionStateEpoch += 1 + canonicalStateEpoch += 1 + pendingSnapshot = null + val activeWrite = pendingWrite + pendingWrite = null + _enabled.value = true + _prefs.value = OverlaySchema.buildDefaults() + confirmedPrefs = _prefs.value + adminDefaultsRaw = null + hasUserOverride = false + confirmedHasUserOverride = false + hasHydrated = false + // Let the next authenticated session queue its refresh behind any + // old in-flight request. Otherwise hydrateIfNeeded() observes the + // previous session's loading flag, returns, and never retries. + _isLoading.value = false + _lastError.value = null + activeWrite + } inflight?.cancel() - _enabled.value = true - _prefs.value = OverlaySchema.buildDefaults() - adminDefaultsRaw = null - hasUserOverride = false - hasHydrated = false - _lastError.value = null } + private data class RefreshState( + val sessionStateEpoch: Long, + val canonicalStateEpoch: Long, + val adminDefaultsRaw: String?, + ) + + private data class ResetState( + val writeGeneration: Int, + val canonicalStateEpoch: Long, + ) + companion object { - const val OVERLAY_SETTING_KEY = "card_overlays" + const val OVERLAY_SETTING_KEY = SettingKeys.UI_CARD_OVERLAYS } } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/PlayerSettingsStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/PlayerSettingsStore.kt index 982d00814..50d496e42 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/PlayerSettingsStore.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/PlayerSettingsStore.kt @@ -27,15 +27,8 @@ interface PlayerSettingsStore { // Ints val audioSyncMsFlow: Flow + /** Canonical device-scoped subtitle offset (`player.subtitle_sync_ms`). */ val subtitleSyncMsFlow: Flow - - /** - * Subtitle sync for one catalog item: its own override when it has one, - * otherwise the profile-wide value. A badly timed release is a property of - * that release, so correcting it must not silently shift every other title - * — which is what a single global value did. - */ - fun subtitleSyncMsFor(contentId: String?): Flow val nextUpPromptSecondsFlow: Flow val sleepTimerDefaultMinutesFlow: Flow /** Seconds to skip back on resume (F1). Default 7; 0 = off. Local-only. */ @@ -43,6 +36,13 @@ interface PlayerSettingsStore { /** Consecutive auto-advances before the "Still watching?" prompt (F2). Default 3; 0 = off. Local-only. */ val passOutThresholdFlow: Flow + /** + * The bandwidth half of the quality choice, orthogonal to + * [preferredQualityFlow]. null is uncapped, which is the absence of a + * stored value rather than a sentinel. + */ + val maxBitrateKbpsFlow: Flow + // Strings val preferredQualityFlow: Flow val audioLanguageFlow: Flow @@ -86,13 +86,6 @@ interface PlayerSettingsStore { suspend fun setAudioSyncMs(value: Int) suspend fun setSubtitleSyncMs(value: Int) - - /** - * Record sync for one item. Passing the profile-wide value clears the - * override instead of storing a redundant copy, so an item only carries an - * entry while it genuinely differs. - */ - suspend fun setSubtitleSyncMsFor(contentId: String, value: Int) suspend fun setNextUpPromptSeconds(value: Int) suspend fun setSleepTimerDefaultMinutes(value: Int) /** Set resume skip-back seconds (clamped 0..30; 0 = off). */ @@ -101,12 +94,33 @@ interface PlayerSettingsStore { suspend fun setPassOutThreshold(value: Int) suspend fun setPreferredQuality(value: String) + + /** + * Set both quality axes at once — the two values one picker preset + * decomposes into. Writing them together is what keeps the pair + * consistent: a resolution stored without its bitrate is a combination no + * preset covers, which the picker then has to render as "custom". + * [bitrateKbps] null is uncapped. + */ + suspend fun setQuality(resolution: String, bitrateKbps: Int?) suspend fun setAudioLanguage(value: String) suspend fun setVideoGravity(value: String) suspend fun setOrientationMode(value: String) suspend fun setSubtitleAppearance(value: SubtitleAppearance) + /** + * Project the granular, client-local `subtitle.*` fields into the + * composite `playback.subtitle_appearance` and enqueue it. + * + * The contract carries subtitle appearance as one object and has no + * definitions for the individual fields, so a per-field edit is + * device-local until it is folded into the composite. Call this after + * editing fields individually (the player HUD, a per-field picker); a + * no-op when the projection already matches what is stored. + */ + suspend fun flushProjectedSubtitleAppearance() + /** * Pull every device-scoped setting from `/api/v1/settings/effective` * and write the resolved values into the local DataStore without diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusher.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusher.kt index c42591a38..7a3b05e6c 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusher.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusher.kt @@ -1,56 +1,149 @@ package org.prairieserver.prairie.common.settings -import android.util.Log +import org.prairieserver.prairie.common.diagnostics.SiloLog +import org.prairieserver.prairie.model.diagnostics.DiagnosticsLogCategory +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.model.settings.SettingScopeIdentity import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.api.SettingsApi +import org.prairieserver.prairie.network.api.newSettingMutationId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive interface ServerSettingsFlusher { - fun enqueue(profileId: String, key: String, value: String) - fun enqueueDelete(profileId: String, key: String) + /** + * Queue one device-scoped write. Values arrive as the store's plain + * strings and are encoded to the contract's JSON type on the wire. + * + * Each logical write gets one idempotency id ([newSettingMutationId]) + * that is held for every retry of that write — the server replays the + * recorded receipt for a repeated id, so a retry after a dropped + * response cannot double-apply. Enqueueing a *different* value for the + * same key replaces the pending op and mints a fresh id, because + * reusing an id for different content is a 409 conflict by design. + * + * [serverUrl] is the server the value was authored against. This flusher + * is application-scoped and its requests are relative — they go to + * whichever server is active when they are sent — so an op that outlives + * a server switch has to be identified by its origin, not just by + * (profileId, key). See [ServerSettingsFlusher] implementations for what + * happens to an op whose origin is no longer active. + */ + fun enqueue(profileId: String, key: String, value: String, serverUrl: String) + + /** Queue clearing the device-scoped value, so the setting inherits again. */ + fun enqueueDelete(profileId: String, key: String, serverUrl: String) /** * Cancel any in-flight debounce, drain every pending op, and suspend * until each one has been ack'd (or errored). Mirrors the iOS * `PlayerSettings.flushPendingDeviceSettings()` semantics — * re-entrant calls coalesce, and the second caller waits for the - * first to finish. + * first to finish. Ops that fail transiently stay queued for retry. */ suspend fun flushNow() } private sealed class PendingOp { - data class Set(val value: String) : PendingOp() - object Delete : PendingOp() + /** The server this op was authored against; it may not still be active. */ + abstract val serverUrl: String + + data class Set( + val value: String, + val mutationId: String, + override val serverUrl: String, + ) : PendingOp() + + data class Delete(override val serverUrl: String) : PendingOp() } +/** + * Debounced writer for the canonical settings API + * (`PUT/DELETE /api/v1/settings/values/{key}?scope=profile_device`). + * + * Failure handling is the point, not an afterthought: a write that fails + * for a transient reason (network, 5xx, 429/408/401) stays queued and is + * retried with the SAME mutation id, first on a capped backoff schedule and + * after that on the next enqueue/flushNow trigger — it is never silently + * dropped, which is how a server hiccup used to turn settings + * non-persistent. Only a response that proves retrying is pointless (the + * contract rejected the value or key, or the mutation id was reused for + * different content) drops the op, and every failure is logged at warning + * level with the key and status. + * + * That retention is bounded by the server the op was authored against. + * [SettingsApi] requests are relative and this flusher is application-scoped, + * so they address whichever server is active when they are sent — while a + * server switch is one `onSelect` away and clears nothing. A retained op + * whose origin is no longer active is therefore dropped rather than sent: + * replaying it would write one server's device setting to another (a + * restored or cloned server can hold the same profile id), and leaving it + * queued would let a later enqueue revive it against a third. Persistence is + * worth a lot, but not worth writing a value to a server the user never + * authored it against. + */ class DefaultServerSettingsFlusher( private val settingsApi: SettingsApi, private val scope: CoroutineScope, private val debounceMs: Long = 750, + /** + * The server requests currently address. Null (no active server, e.g. + * mid-logout) parks the queue rather than dropping it: there is nothing + * to compare against yet, and a switch has not been observed. + */ + private val getServerUrl: suspend () -> String? = { null }, ) : ServerSettingsFlusher { private val lock = Any() private val pending = mutableMapOf, PendingOp>() private var flushJob: Job? = null + private var retryJob: Job? = null + private var retryAttempts: Int = 0 private val flushMutex = Mutex() - override fun enqueue(profileId: String, key: String, value: String) { - scheduleDebounced(profileId, key, PendingOp.Set(value)) + override fun enqueue(profileId: String, key: String, value: String, serverUrl: String) { + scheduleDebounced(profileId, key) { existing -> + // Re-enqueueing the identical value keeps the pending op (and + // its mutation id): it is the same logical write, and the + // server treats a replayed id + content as already done. A match + // has to agree on the origin too — the same key and value bound + // for a different server is a different write. + if (existing is PendingOp.Set && + existing.value == value && + existing.serverUrl == serverUrl + ) { + existing + } else { + PendingOp.Set(value, newSettingMutationId(), serverUrl) + } + } } - override fun enqueueDelete(profileId: String, key: String) { - scheduleDebounced(profileId, key, PendingOp.Delete) + override fun enqueueDelete(profileId: String, key: String, serverUrl: String) { + scheduleDebounced(profileId, key) { PendingOp.Delete(serverUrl) } } - private fun scheduleDebounced(profileId: String, key: String, op: PendingOp) { + private fun scheduleDebounced( + profileId: String, + key: String, + op: (PendingOp?) -> PendingOp, + ) { synchronized(lock) { - pending[profileId to key] = op + val composite = profileId to key + pending[composite] = op(pending[composite]) + // Fresh user activity re-arms the retry budget. + retryAttempts = 0 + retryJob?.cancel() + retryJob = null flushJob?.cancel() flushJob = scope.launch { delay(debounceMs) @@ -63,46 +156,249 @@ class DefaultServerSettingsFlusher( synchronized(lock) { flushJob?.cancel() flushJob = null + retryJob?.cancel() + retryJob = null + retryAttempts = 0 } drainAndFlush() } private suspend fun drainAndFlush() { flushMutex.withLock { + // Ops that failed transiently in this drain. Kept out of + // `pending` until the loop below finishes, or the loop would + // retry them immediately and spin. + // + // Only the LATEST outcome per key may live here. A later pass of + // the drain loop that settles a newer op for the same key — + // whether it landed or the contract refused it — must evict the + // older failed entry, or the re-queue below would resurrect a + // superseded value and `scheduleRetry` would replay it over the + // newer one. The post-loop `composite !in pending` guard cannot + // catch that case: the very pass that sent the newer op already + // cleared `pending`. + val retryable = LinkedHashMap, PendingOp>() while (true) { val snapshot: Map, PendingOp> = synchronized(lock) { - if (pending.isEmpty()) return@withLock val copy = pending.toMap() pending.clear() copy } + if (snapshot.isEmpty()) break snapshot.forEach { (composite, op) -> val (profileId, key) = composite - flushOne(profileId, key, op) + if (flushOne(profileId, key, op)) { + retryable[composite] = op + } else { + retryable.remove(composite) + } } } + if (retryable.isEmpty()) { + synchronized(lock) { retryAttempts = 0 } + return + } + val attempt = synchronized(lock) { + for ((composite, op) in retryable) { + // A newer op enqueued during the flush wins over the + // failed one — it is newer content with its own id. + if (composite !in pending) pending[composite] = op + } + if (retryAttempts >= MAX_AUTO_RETRIES) { + // Out of automatic retries: the ops stay queued and the + // next enqueue or flushNow (app foreground, player exit) + // tries again with the same mutation ids. + null + } else { + ++retryAttempts + } + } + if (attempt != null) scheduleRetry(attempt) } } - private suspend fun flushOne(profileId: String, key: String, op: PendingOp) { - try { - val result = when (op) { - is PendingOp.Set -> - settingsApi.setDeviceSetting(key, op.value, profileId = profileId) - is PendingOp.Delete -> - settingsApi.deleteDeviceSetting(key) + private fun scheduleRetry(attempt: Int) { + synchronized(lock) { + retryJob?.cancel() + retryJob = scope.launch { + delay(retryDelayMs(attempt)) + drainAndFlush() } - if (result is ApiResult.Error) { - Log.w(TAG, "$op profile=$profileId key=$key code=${result.code}: ${result.message}") - } else if (result is ApiResult.NetworkError) { - Log.w(TAG, "$op profile=$profileId key=$key network error: ${result.exception}") + } + } + + /** + * Sends one op. Returns true when it must stay queued for retry — + * with its mutation id unchanged, so the retry is an idempotent replay + * rather than a second write. + */ + private suspend fun flushOne(profileId: String, key: String, op: PendingOp): Boolean { + val active = runCatching { getServerUrl() }.getOrNull() + if (active != null && active != op.serverUrl) { + // The user switched servers while this op was queued. Requests are + // relative, so sending it now would address the NEW server with a + // value authored for the old one — and a restored or cloned server + // can recognize the same profile id, so it would land rather than + // fail. Dropping it also keeps a stale op from being revived by a + // later enqueue once the original server is active again. + SiloLog.w( + CATEGORY, TAG, + "dropping $key: queued for ${op.serverUrl}, active server is now $active", + ) + return false + } + if (key !in REMOTE_KEYS) { + // Local-only keys (granular subtitle.* fields, pre-contract + // strays) have no server row; the canonical API would refuse + // them as unknown_setting, so they never leave the device. + SiloLog.w(CATEGORY, TAG, "dropping $key: not a server-stored key in the generated contract") + return false + } + return try { + when (op) { + is PendingOp.Set -> flushSet(profileId, key, op) + is PendingOp.Delete -> flushDelete(profileId, key) } } catch (t: Throwable) { - Log.w(TAG, "$op profile=$profileId key=$key threw: $t") + // Includes cancellation of a superseded flush: the op goes back + // into the queue and the next trigger replays it, so a torn-down + // flush never loses a write. + failed("flush", key, "threw: $t", retry = true) + } + } + + private suspend fun flushSet(profileId: String, key: String, op: PendingOp.Set): Boolean { + val encoded = encodeSettingWireValue(key, op.value) + if (encoded == null) { + SiloLog.w(CATEGORY, TAG, "dropping $key: ${op.value} does not encode as the contract type") + return false + } + return when ( + val result = settingsApi.putValue( + key = key, + scope = SettingScopeIdentity.profileDevice(), + value = encoded, + mutationId = op.mutationId, + profileId = profileId, + ) + ) { + is ApiResult.Success -> false + is ApiResult.Error -> failed( + "put", key, "${result.code} ${result.error}: ${result.message}", + retry = isTransientHttp(result.code), + ) + is ApiResult.NetworkError -> + failed("put", key, "network error: ${result.exception}", retry = true) } } + private suspend fun flushDelete(profileId: String, key: String): Boolean { + return when ( + val result = settingsApi.deleteValue( + key = key, + scope = SettingScopeIdentity.profileDevice(), + profileId = profileId, + ) + ) { + is ApiResult.Success -> false + is ApiResult.Error -> + if (result.code == 404) { + // Nothing stored at this scope — the reset is already + // true, e.g. an earlier attempt landed before its + // response did. + false + } else { + failed( + "delete", key, "${result.code} ${result.error}: ${result.message}", + retry = isTransientHttp(result.code), + ) + } + is ApiResult.NetworkError -> + failed("delete", key, "network error: ${result.exception}", retry = true) + } + } + + private fun failed(verb: String, key: String, detail: String, retry: Boolean): Boolean { + SiloLog.w( + CATEGORY, TAG, + "$verb $key failed ($detail); ${if (retry) "kept queued for retry" else "dropped"}", + ) + return retry + } + private companion object { const val TAG = "ServerSettingsFlusher" + val CATEGORY = DiagnosticsLogCategory.NETWORK + + val REMOTE_KEYS: Set = SettingKeys.REMOTE.toSet() + + /** + * Retrying can help: the request never arrived, the server fell + * over, throttled us, timed out, or the session token was mid + * refresh. Everything else is the contract refusing the write — + * invalid value, unknown key, scope not allowed, or a mutation id + * reused for different content (409) — where a retry would fail + * identically forever. + */ + fun isTransientHttp(code: Int): Boolean = + code >= 500 || code == 408 || code == 429 || code == 401 + + const val MAX_AUTO_RETRIES = 5 + const val RETRY_BASE_DELAY_MS = 1_000L + const val RETRY_MAX_DELAY_MS = 60_000L + + fun retryDelayMs(attempt: Int): Long = + (RETRY_BASE_DELAY_MS shl (attempt - 1).coerceIn(0, 6)) + .coerceAtMost(RETRY_MAX_DELAY_MS) } } + +/** + * The nullable language-tag keys, where the store spells "no preference" as + * the empty string but the contract spells it as JSON null (the server's + * language_tag validator rejects `""`). + * + * The generated bindings classify boolean/int/double keys only, so these two + * groups are named here until the generator grows the remaining type sets. + */ +private val NULLABLE_LANGUAGE_KEYS: Set = setOf( + SettingKeys.CATALOG_METADATA_LANGUAGE, + SettingKeys.PLAYBACK_AUDIO_LANGUAGE, + SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, +) + +/** + * Nullable integer keys, where the contract's null means "no cap" and the + * local store has to spell that as some in-band value. Zero is chosen because + * the contract's range (100..200000) cannot hold it, so it can never collide + * with a real cap — but it is not a value the server would accept, so it is + * translated to JSON null rather than sent. + */ +private val NULLABLE_INT_KEYS: Set = setOf( + SettingKeys.PLAYBACK_MAX_BITRATE_KBPS, +) + +/** Keys whose store-side string is itself a JSON object document. */ +private val SETTING_OBJECT_KEYS: Set = setOf( + SettingKeys.PLAYBACK_SUBTITLE_APPEARANCE, +) + +/** + * Encodes the store's string spelling of a value as the JSON type the + * contract declares for [key], classified by the generated + * [SettingKeys.BOOLEAN_KEYS]/[SettingKeys.INT_KEYS]/[SettingKeys.DOUBLE_KEYS] + * sets. Returns null when the string cannot be that type — a client bug the + * server would reject with `invalid_value`, so the caller drops it loudly + * instead of retrying it forever. + */ +internal fun encodeSettingWireValue(key: String, raw: String): JsonElement? = when { + key in SettingKeys.BOOLEAN_KEYS -> raw.toBooleanStrictOrNull()?.let(::JsonPrimitive) + key in NULLABLE_INT_KEYS -> + raw.toLongOrNull()?.let { if (it <= 0L) JsonNull else JsonPrimitive(it) } + key in SettingKeys.INT_KEYS -> raw.toLongOrNull()?.let(::JsonPrimitive) + key in SettingKeys.DOUBLE_KEYS -> raw.toDoubleOrNull()?.let(::JsonPrimitive) + key in SETTING_OBJECT_KEYS -> + runCatching { Json.parseToJsonElement(raw) }.getOrNull() as? JsonObject + key in NULLABLE_LANGUAGE_KEYS && raw.isEmpty() -> JsonNull + else -> JsonPrimitive(raw) +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt index 02159e9e7..97c1383fd 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt @@ -7,12 +7,14 @@ import org.prairieserver.prairie.common.ui.components.resolveAvatarUrl import org.prairieserver.prairie.model.profile.Profile import org.prairieserver.prairie.model.section.ResolvedSection import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.repository.AuthRepository import org.prairieserver.prairie.repository.PersonalDataRepository import org.prairieserver.prairie.repository.ProfileRepository import org.prairieserver.prairie.repository.SectionRepository import org.prairieserver.prairie.repository.port.HomeCachePort -import org.prairieserver.prairie.util.ArtworkUrl +import org.prairieserver.prairie.repository.port.HomeCacheWriteLease +import org.prairieserver.prairie.viewmodel.hydrateHomeSections import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -83,6 +85,7 @@ suspend fun warmAuthenticatedStartup( personalDataRepository: PersonalDataRepository, sectionRepository: SectionRepository, homeCache: HomeCachePort, + identityTransitions: IdentityTransitionBarrier, serverUrl: String?, artworkPlan: StartupArtworkPlan, ) { @@ -106,7 +109,15 @@ suspend fun warmAuthenticatedStartup( Unit }, async { - runCatching { warmHome(context, sectionRepository, homeCache, artworkPlan) } + runCatching { + warmHome( + context, + sectionRepository, + homeCache, + identityTransitions, + artworkPlan, + ) + } Unit }, ).awaitAll() @@ -135,27 +146,23 @@ private suspend fun CoroutineScope.warmHome( context: Context, sectionRepository: SectionRepository, homeCache: HomeCachePort, + identityTransitions: IdentityTransitionBarrier, artworkPlan: StartupArtworkPlan, ) { + val requestIdentityGeneration = identityTransitions.generation.value + val cacheWriteLease = HomeCacheWriteLease(requestIdentityGeneration) when (val result = sectionRepository.getHomeSections()) { is ApiResult.Success -> { - val resolvedPairs: List> = - result.data.sections.map { section -> - async { - when (val itemsResult = sectionRepository.getHomeSectionItems(section.id)) { - is ApiResult.Success -> (itemsResult.data.section ?: section) to true - is ApiResult.Error, - is ApiResult.NetworkError -> section to false - } - } - }.awaitAll() - - if (resolvedPairs.all { it.second }) { - val resolved = resolvedPairs.map { it.first }.filter { it.items.isNotEmpty() } - if (resolved.isNotEmpty()) { - homeCache.cacheHome(resolved) - warmHomeArtwork(context, resolved, artworkPlan) - } + val hydration = hydrateHomeSections(result.data.sections) { sectionId -> + sectionRepository.getHomeSectionItems(sectionId) + } + if ( + hydration.fullyResolved && + hydration.sections.isNotEmpty() && + requestIdentityGeneration == identityTransitions.generation.value + ) { + homeCache.cacheHome(hydration.sections, cacheWriteLease) + warmHomeArtwork(context, hydration.sections, artworkPlan) } } is ApiResult.Error, @@ -180,12 +187,8 @@ private suspend fun warmHomeArtwork( fun append(urlString: String?, widthPx: Int, heightPx: Int) { if (requests.size >= plan.maxUrls) return - val raw = urlString?.trim().orEmpty() - if (raw.isEmpty()) return - // Warm the preferred format for this device so ThumbhashImage's first - // request hits the Coil disk/memory cache. - val url = ArtworkUrl.preferred(raw) - if (!seen.add(url)) return + val url = urlString?.trim().orEmpty() + if (url.isEmpty() || !seen.add(url)) return requests.add( ImageRequest.Builder(context) .data(url) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/store/ScopedJsonFileStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/store/ScopedJsonFileStore.kt index a342eabb8..d95aef83a 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/store/ScopedJsonFileStore.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/store/ScopedJsonFileStore.kt @@ -145,7 +145,7 @@ internal class ScopedJsonFileStore( private fun createExclusiveTemp(parent: File): ExclusiveTemp? { repeat(MAX_TEMP_ATTEMPTS) { - val candidate = File(parent, ".silo-${UUID.randomUUID()}.tmp") + val candidate = File(parent, ".prairie-${UUID.randomUUID()}.tmp") var directoryStream: java.nio.file.DirectoryStream? = null try { directoryStream = Files.newDirectoryStream(parent.toPath()) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/DirectorCredit.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/DirectorCredit.kt new file mode 100644 index 000000000..2dc5a08a3 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/DirectorCredit.kt @@ -0,0 +1,17 @@ +package org.prairieserver.prairie.common.ui + +import org.prairieserver.prairie.model.catalog.ItemDetail + +fun movieDirectorCredit(detail: ItemDetail): String? { + if (!detail.type.equals("movie", ignoreCase = true)) return null + val names = detail.crew + .asSequence() + .filter { it.job?.trim().equals("Director", ignoreCase = true) } + .map { it.name.trim() } + .filter { it.isNotEmpty() } + .distinct() + .take(3) + .toList() + return names.takeIf { it.isNotEmpty() } + ?.joinToString(prefix = "Directed by ", separator = ", ") +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepositoryTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepositoryTest.kt index 07fd65208..9504664a5 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepositoryTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepositoryTest.kt @@ -2,11 +2,16 @@ package org.prairieserver.prairie.common.data.repository import androidx.room.Room import androidx.test.core.app.ApplicationProvider -import org.prairieserver.prairie.common.data.db.PrairieDatabase +import org.prairieserver.prairie.common.data.db.SiloDatabase import org.prairieserver.prairie.model.catalog.BrowseItem import org.prairieserver.prairie.model.catalog.CatalogResponse import org.prairieserver.prairie.model.personal.UserLibrary import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionKind +import org.prairieserver.prairie.repository.port.CatalogCacheWriteLease +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async import kotlinx.coroutines.test.runTest import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -20,7 +25,7 @@ class RoomCatalogCacheRepositoryTest { private val db = Room.inMemoryDatabaseBuilder( ApplicationProvider.getApplicationContext(), - PrairieDatabase::class.java, + SiloDatabase::class.java, ).allowMainThreadQueries().build() private var scope: AuthScopeSnapshot? = AuthScopeSnapshot("s1", "p1", "https://s1.example", null) @@ -69,4 +74,56 @@ class RoomCatalogCacheRepositoryTest { scope = null assertNull(repo.getCachedLibraries()) } + + @Test + fun writeStartedBeforeProfileSwitchIsNotAttributedToNewProfile() = runTest { + val snapshotRequested = CompletableDeferred() + val releaseSnapshot = CompletableDeferred() + val identityTransitions = DefaultIdentityTransitionBarrier() + val delayedRepo = RoomCatalogCacheRepository( + db = db, + snapshotProvider = { + snapshotRequested.complete(Unit) + releaseSnapshot.await() + scope + }, + identityTransitions = identityTransitions, + now = { 1000L }, + ) + + val oldProfileWrite = async { + delayedRepo.cacheLibraries(listOf(UserLibrary(id = 1, name = "Profile A", type = "movie"))) + } + snapshotRequested.await() + identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { + scope = AuthScopeSnapshot("s1", "p2", "https://s1.example", null) + } + releaseSnapshot.complete(Unit) + oldProfileWrite.await() + + assertNull(delayedRepo.getCachedLibraries()) + } + + @Test + fun writeRequestedByOldProfileButInvokedAfterSwitchIsNotAttributedToNewProfile() = runTest { + val identityTransitions = DefaultIdentityTransitionBarrier() + val oldProfileGeneration = identityTransitions.generation.value + val guardedRepo = RoomCatalogCacheRepository( + db = db, + snapshotProvider = { scope }, + identityTransitions = identityTransitions, + now = { 1000L }, + ) + identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { + scope = AuthScopeSnapshot("s1", "p2", "https://s1.example", null) + } + + guardedRepo.cacheLibraries( + listOf(UserLibrary(id = 1, name = "Profile A", type = "movie")), + CatalogCacheWriteLease(oldProfileGeneration), + ) + + assertEquals(0L, oldProfileGeneration) + assertNull(guardedRepo.getCachedLibraries()) + } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepositoryTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepositoryTest.kt index a9dfe5c46..e4fae1016 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepositoryTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepositoryTest.kt @@ -2,10 +2,15 @@ package org.prairieserver.prairie.common.data.repository import androidx.room.Room import androidx.test.core.app.ApplicationProvider -import org.prairieserver.prairie.common.data.db.PrairieDatabase +import org.prairieserver.prairie.common.data.db.SiloDatabase import org.prairieserver.prairie.model.section.ResolvedSection import org.prairieserver.prairie.model.section.SectionItem import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionKind +import org.prairieserver.prairie.repository.port.HomeCacheWriteLease +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async import kotlinx.coroutines.test.runTest import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -19,7 +24,7 @@ class RoomHomeCacheRepositoryTest { private val db = Room.inMemoryDatabaseBuilder( ApplicationProvider.getApplicationContext(), - PrairieDatabase::class.java, + SiloDatabase::class.java, ).allowMainThreadQueries().build() private var scope: AuthScopeSnapshot? = AuthScopeSnapshot("s1", "p1", "https://s1.example", null) @@ -86,4 +91,56 @@ class RoomHomeCacheRepositoryTest { scope = null assertNull(repo.getCachedHome()) } + + @Test + fun writeRequestedByOldProfileButInvokedAfterSwitchIsNotAttributedToNewProfile() = runTest { + val identityTransitions = DefaultIdentityTransitionBarrier() + val oldProfileGeneration = identityTransitions.generation.value + val guardedRepo = RoomHomeCacheRepository( + db = db, + snapshotProvider = { scope }, + identityTransitions = identityTransitions, + now = { 1000L }, + ) + identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { + scope = AuthScopeSnapshot("s1", "p2", "https://s1.example", null) + } + + guardedRepo.cacheHome( + listOf(section("old", "c1")), + HomeCacheWriteLease(oldProfileGeneration), + ) + + assertEquals(0L, oldProfileGeneration) + assertNull(guardedRepo.getCachedHome()) + } + + @Test + fun profileSwitchDuringHomeScopeResolutionDoesNotAttributeOldWriteToNewProfile() = runTest { + val snapshotRequested = CompletableDeferred() + val releaseSnapshot = CompletableDeferred() + val identityTransitions = DefaultIdentityTransitionBarrier() + val guardedRepo = RoomHomeCacheRepository( + db = db, + snapshotProvider = { + snapshotRequested.complete(Unit) + releaseSnapshot.await() + scope + }, + identityTransitions = identityTransitions, + now = { 1000L }, + ) + + val oldProfileWrite = async { + guardedRepo.cacheHome(listOf(section("old", "c1"))) + } + snapshotRequested.await() + identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { + scope = AuthScopeSnapshot("s1", "p2", "https://s1.example", null) + } + releaseSnapshot.complete(Unit) + oldProfileWrite.await() + + assertNull(guardedRepo.getCachedHome()) + } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorkerProgressTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorkerProgressTest.kt new file mode 100644 index 000000000..6ce5d2353 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorkerProgressTest.kt @@ -0,0 +1,42 @@ +package org.prairieserver.prairie.common.downloads + +import org.prairieserver.prairie.model.download.DownloadRecord +import org.prairieserver.prairie.model.download.DownloadStatus +import kotlin.test.Test +import kotlin.test.assertEquals + +class DownloadWorkerProgressTest { + private val record = DownloadRecord( + id = "download-1", + contentId = "content-1", + mediaFileId = 7, + fileSize = 10_000, + bytesSent = 4_000, + kind = "download", + status = DownloadStatus.Downloading.wire, + createdAt = "2026-07-28T00:00:00Z", + ) + + @Test + fun `permanent failure explicitly clears stale progress`() { + val failed = record.withWorkerStatus( + status = DownloadStatus.Failed.wire, + bytesSent = 0, + fileSize = 0, + ) + + assertEquals(DownloadStatus.Failed.wire, failed.status) + assertEquals(0, failed.bytesSent) + assertEquals(0, failed.fileSize) + } + + @Test + fun `omitted progress preserves resume state`() { + val downloading = record.withWorkerStatus( + status = DownloadStatus.Downloading.wire, + ) + + assertEquals(4_000, downloading.bytesSent) + assertEquals(10_000, downloading.fileSize) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OrphanedServerDataPurgerTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OrphanedServerDataPurgerTest.kt index af784e41a..385057af9 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OrphanedServerDataPurgerTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OrphanedServerDataPurgerTest.kt @@ -7,12 +7,14 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -374,6 +376,8 @@ class OrphanedServerDataPurgerTest { val finishInitialPurge = CompletableDeferred() val initialRowsPurged = CompletableDeferred() val rowsPurged = CompletableDeferred() + val secondPurgeStarted = CompletableDeferred() + val allowSecondPurge = CompletableDeferred() val purger = OrphanedServerDataPurger( registry = registry, purgeDao = db.serverPurgeDao(), @@ -387,6 +391,10 @@ class OrphanedServerDataPurgerTest { initialPurgeStarted.complete(Unit) finishInitialPurge.await() } + if (orphanId == serverId) { + secondPurgeStarted.complete(Unit) + allowSecondPurge.await() + } db.serverPurgeDao().deleteAllRowsForServer(orphanId) if (orphanId == "preexisting-orphan") { initialRowsPurged.complete(Unit) @@ -400,21 +408,31 @@ class OrphanedServerDataPurgerTest { val observer = purger.start() var observerFailure: Throwable? = null observer.invokeOnCompletion { observerFailure = it } - initialPurgeStarted.await() - registry.remove(serverId) - assertTrue(registry.entries.value.none { it.id == serverId }) - finishInitialPurge.complete(Unit) - initialRowsPurged.await() - assertTrue(observer.isActive, "purge observer stopped: $observerFailure") - assertNull(db.downloadDao().get("preexisting-orphan", "p1", 11)) - assertTrue( - db.downloadDao().get(serverId, "p1", 10) != null, - "removed server was unexpectedly part of the startup orphan snapshot", - ) - - rowsPurged.await() - assertNull(db.downloadDao().get(serverId, "p1", 10)) - observer.cancel() + try { + initialPurgeStarted.await() + registry.remove(serverId) + assertTrue(registry.entries.value.none { it.id == serverId }) + finishInitialPurge.complete(Unit) + initialRowsPurged.await() + secondPurgeStarted.await() + assertTrue(observer.isActive, "purge observer stopped: $observerFailure") + assertNull(db.downloadDao().get("preexisting-orphan", "p1", 11)) + assertTrue( + db.downloadDao().get(serverId, "p1", 10) != null, + "second-pass deletion must remain gated until the snapshot assertion completes", + ) + + allowSecondPurge.complete(Unit) + rowsPurged.await() + assertNull(db.downloadDao().get(serverId, "p1", 10)) + } finally { + withContext(NonCancellable) { + finishInitialPurge.complete(Unit) + allowSecondPurge.complete(Unit) + observer.cancel() + observer.join() + } + } } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/pairing/RegistryPairingAuthPortTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/pairing/RegistryPairingAuthPortTest.kt index 66ac07de9..ee8049182 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/pairing/RegistryPairingAuthPortTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/pairing/RegistryPairingAuthPortTest.kt @@ -29,7 +29,7 @@ class RegistryPairingAuthPortTest { assertFailsWith { RegistryPairingAuthPort(tokens, registry, consent).persistApprovedSession( - serverUrl = "http://silo.lan", + serverUrl = "http://prairie.lan", serverName = "Unsafe", accessToken = "access", refreshToken = "refresh", diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/AudiobookPlayerTeardownSourceTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/AudiobookPlayerTeardownSourceTest.kt new file mode 100644 index 000000000..841e14516 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/AudiobookPlayerTeardownSourceTest.kt @@ -0,0 +1,38 @@ +package org.prairieserver.prairie.common.player + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AudiobookPlayerTeardownSourceTest { + private val viewModelSource = File( + requireNotNull(System.getProperty("user.dir")), + "src/androidMain/kotlin/org/prairieserver/prairie/common/player/AudiobookPlayerViewModel.kt", + ).readText() + + private val onClearedSource = viewModelSource + .substringAfter("override fun onCleared() {") + .substringBefore("\n companion object") + + @Test + fun `onCleared captures and submits external session finalization without blocking`() { + assertFalse(onClearedSource.contains("runBlocking")) + assertTrue(onClearedSource.contains("val state = _uiState.value")) + assertTrue(onClearedSource.contains("val sessionId = state.sessionId")) + assertTrue( + onClearedSource.contains( + "val positionSeconds = sessionLocalPosition(state)", + ), + ) + assertTrue(onClearedSource.contains("val isPaused = true")) + assertTrue( + onClearedSource.contains( + "playbackSessionLifecycle.reportAndStopExternalSessionAsync(", + ), + ) + assertTrue(onClearedSource.contains("sessionId = sessionId")) + assertTrue(onClearedSource.contains("positionSeconds = positionSeconds")) + assertTrue(onClearedSource.contains("isPaused = isPaused")) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicyTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicyTest.kt index bd0eb444c..4bfde731a 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicyTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicyTest.kt @@ -2,140 +2,198 @@ package org.prairieserver.prairie.common.player import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse +import kotlin.test.assertFailsWith import kotlin.test.assertTrue class PlaybackBufferPolicyTest { - @Test - fun profilesExposeExpectedStartupAndRebufferTargets() { - val quick = PlaybackBufferPolicy.forMode(PlaybackBufferMode.QuickStart) - val balanced = PlaybackBufferPolicy.forMode(PlaybackBufferMode.Balanced) - val smooth = PlaybackBufferPolicy.forMode(PlaybackBufferMode.SmoothPlayback) - - assertEquals(2_000, quick.bufferForPlaybackMs) - assertEquals(6_000, quick.bufferForPlaybackAfterRebufferMs) - assertEquals(3_000, balanced.bufferForPlaybackMs) - assertEquals(10_000, balanced.bufferForPlaybackAfterRebufferMs) - assertEquals(5_000, smooth.bufferForPlaybackMs) - assertEquals(15_000, smooth.bufferForPlaybackAfterRebufferMs) - } + private val roomy = PlaybackBufferDeviceProfile(memoryClassMb = 512, isLowRamDevice = false) + private val lowRam = PlaybackBufferDeviceProfile(memoryClassMb = 96, isLowRamDevice = true) + // The load control stops reading the socket once the buffer reaches + // maxBufferMs and does not resume until it drains below minBufferMs, so + // this gap IS how long the connection sits idle. An upstream proxy with a + // 60s send timeout drops it if the gap approaches that. This is the + // property the whole design exists to guarantee. @Test - fun profilesKeepBufferDurationsInValidOrder() { - PlaybackBufferMode.entries.forEach { mode -> - val policy = PlaybackBufferPolicy.forMode(mode) - assertTrue(policy.bufferForPlaybackMs <= policy.minBufferMs, mode.name) - assertTrue(policy.bufferForPlaybackAfterRebufferMs <= policy.minBufferMs, mode.name) - assertTrue(policy.minBufferMs <= policy.maxBufferMs, mode.name) + fun `idle window is bounded for every device profile`() { + listOf(roomy, lowRam, PlaybackBufferDeviceProfile.Unknown).forEach { profile -> + val policy = PlaybackBufferPolicy.forConditions(profile) + assertEquals( + PlaybackBufferPolicy.MAX_LOAD_IDLE_MS, + policy.maxBufferMs - policy.minBufferMs, + "idle window for $profile", + ) } } @Test - fun quickStartUsesHeapBoundedByteCapForHighBitrate4kDirectPlay() { - val policy = PlaybackBufferPolicy.forMode(PlaybackBufferMode.QuickStart, roomyDevice) + fun `idle window stays well under the proxy send timeout it guards against`() { + assertTrue( + PlaybackBufferPolicy.MAX_LOAD_IDLE_MS * 2 <= + PlaybackBufferPolicy.ASSUMED_PROXY_SEND_TIMEOUT_MS, + "idle window should keep a wide margin below the assumed timeout", + ) + } - assertEquals(128 * 1024 * 1024, policy.targetBufferBytes) - assertFalse(policy.prioritizeTimeOverSizeThresholds) - assertTrue(policy.maxBufferMs <= 60_000) + // The idle window is expressed in MEDIA time, but a proxy's send_timeout + // measures WALL CLOCK time, and DefaultLoadControl only scales + // minBufferUs for speeds ABOVE 1.0 — not below it. Audiobooks share this + // load control and the UI offers rates down to + // PlaybackBufferPolicy.SLOWEST_PLAYBACK_SPEED (0.5x), where one + // media-time second of idle window takes two wall-clock seconds. This + // asserts the window still fits inside the assumed proxy timeout once + // stretched by that slowest rate, not just at 1.0x. + // + // The comparison is strict on purpose. Equality is not "fits" — a socket + // that goes quiet for exactly the timeout is a race the proxy wins about + // as often as we do. It also matters for what this test is FOR: with a + // non-strict comparison, reverting MAX_LOAD_IDLE_MS to the pre-fix 30_000 + // gives 60_000 <= 60_000 and the guard passes, silently readmitting the + // exact bug this work removed. + @Test + fun `idle window still fits the proxy timeout once stretched by the slowest playback speed`() { + val stretchedWallClockMs = + PlaybackBufferPolicy.MAX_LOAD_IDLE_MS / PlaybackBufferPolicy.SLOWEST_PLAYBACK_SPEED + + assertTrue( + stretchedWallClockMs < PlaybackBufferPolicy.ASSUMED_PROXY_SEND_TIMEOUT_MS, + "idle window ($stretchedWallClockMs ms wall clock at " + + "${PlaybackBufferPolicy.SLOWEST_PLAYBACK_SPEED}x) should still fit inside the " + + "assumed proxy timeout (${PlaybackBufferPolicy.ASSUMED_PROXY_SEND_TIMEOUT_MS} ms)", + ) } @Test - fun smoothPlaybackUsesHeapBoundedByteCapForHighBitrateRemuxes() { - val policy = PlaybackBufferPolicy.forMode(PlaybackBufferMode.SmoothPlayback, roomyDevice) + fun `playback starts on a small cushion and recovers quickly after a stall`() { + val policy = PlaybackBufferPolicy.forConditions(roomy) + assertEquals(2_000, policy.bufferForPlaybackMs) + assertEquals(5_000, policy.bufferForPlaybackAfterRebufferMs) + } - assertEquals(192 * 1024 * 1024, policy.targetBufferBytes) - assertFalse(policy.prioritizeTimeOverSizeThresholds) - assertTrue(policy.maxBufferMs <= 180_000) + @Test + fun `depth stays within the declared floor and ceiling`() { + listOf(roomy, lowRam, PlaybackBufferDeviceProfile.Unknown).forEach { profile -> + val policy = PlaybackBufferPolicy.forConditions(profile) + assertTrue(policy.minBufferMs >= PlaybackBufferPolicy.MIN_DEPTH_MS, "floor for $profile") + assertTrue(policy.minBufferMs <= PlaybackBufferPolicy.MAX_DEPTH_MS, "ceiling for $profile") + } } @Test - fun smoothPlaybackPrioritizesDeepForwardBufferingOverQuickStartup() { - val policy = PlaybackBufferPolicy.forMode(PlaybackBufferMode.SmoothPlayback) + fun `startup thresholds never exceed the depth the policy asks for`() { + listOf(roomy, lowRam, PlaybackBufferDeviceProfile.Unknown).forEach { profile -> + val policy = PlaybackBufferPolicy.forConditions(profile) + assertTrue(policy.bufferForPlaybackMs <= policy.minBufferMs, "start for $profile") + assertTrue( + policy.bufferForPlaybackAfterRebufferMs <= policy.minBufferMs, + "rebuffer for $profile", + ) + } + } - assertTrue(policy.bufferForPlaybackMs >= PlaybackBufferPolicy.forMode(PlaybackBufferMode.Balanced).bufferForPlaybackMs) - assertTrue(policy.bufferForPlaybackAfterRebufferMs >= policy.bufferForPlaybackMs * 3) - assertTrue(policy.minBufferMs >= policy.bufferForPlaybackAfterRebufferMs * 3) - assertTrue(policy.maxBufferMs >= policy.minBufferMs * 2) + @Test + fun `a small heap gets exactly half its heap as a buffer budget`() { + // Product ruling: half the heap, not a quarter. A quarter-heap rule + // gives a 96 MB heap only 24 MiB — the exact fixed floor this policy + // replaced, not an improvement on it. + val smallHeap = PlaybackBufferDeviceProfile(memoryClassMb = 96, isLowRamDevice = false) + val budgetBytes = PlaybackBufferPolicy.memoryBudgetBytes(smallHeap) + val halfHeapBytes = (smallHeap.memoryClassMb * 1024 * 1024) / 2 + + assertEquals(halfHeapBytes, budgetBytes) } @Test - fun allProfilesHaveFiniteTargetByteCaps() { - assertEquals(128 * 1024 * 1024, PlaybackBufferPolicy.forMode(PlaybackBufferMode.QuickStart, roomyDevice).targetBufferBytes) - assertEquals(160 * 1024 * 1024, PlaybackBufferPolicy.forMode(PlaybackBufferMode.Balanced, roomyDevice).targetBufferBytes) - assertEquals(192 * 1024 * 1024, PlaybackBufferPolicy.forMode(PlaybackBufferMode.SmoothPlayback, roomyDevice).targetBufferBytes) + fun `NVIDIA Shield measured memoryClass gets half its heap, not a quarter`() { + // Measured via adb: the Shield reports memoryClass=192MB and is not + // flagged low-RAM. Under a quarter-heap rule it would get 48 MiB — + // LESS than the 96 MiB it shipped with before this policy existed. + val shield = PlaybackBufferDeviceProfile(memoryClassMb = 192, isLowRamDevice = false) + + assertEquals(96 * 1024 * 1024, PlaybackBufferPolicy.memoryBudgetBytes(shield)) } @Test - fun lowMemoryDevicesUseSmallerByteCaps() { - val lowMemory = PlaybackBufferDeviceProfile(memoryClassMb = 128, isLowRamDevice = true) + fun `Google TV Streamer measured memoryClass hits the ceiling at half its heap`() { + // Measured via adb: the Streamer reports memoryClass=384MB and is + // not flagged low-RAM. Half of that is exactly the 192 MiB ceiling. + val streamer = PlaybackBufferDeviceProfile(memoryClassMb = 384, isLowRamDevice = false) - assertEquals(32 * 1024 * 1024, PlaybackBufferPolicy.forMode(PlaybackBufferMode.QuickStart, lowMemory).targetBufferBytes) - assertEquals(48 * 1024 * 1024, PlaybackBufferPolicy.forMode(PlaybackBufferMode.Balanced, lowMemory).targetBufferBytes) - assertEquals(64 * 1024 * 1024, PlaybackBufferPolicy.forMode(PlaybackBufferMode.SmoothPlayback, lowMemory).targetBufferBytes) + assertEquals(192 * 1024 * 1024, PlaybackBufferPolicy.memoryBudgetBytes(streamer)) } @Test - fun unknownDevicesUseConstrainedByteCapsUntilMemoryClassIsKnown() { - assertEquals( - 32 * 1024 * 1024, - PlaybackBufferPolicy.forMode( - PlaybackBufferMode.QuickStart, - PlaybackBufferDeviceProfile.Unknown, - ).targetBufferBytes, - ) - assertEquals( - 48 * 1024 * 1024, - PlaybackBufferPolicy.forMode( - PlaybackBufferMode.Balanced, - PlaybackBufferDeviceProfile.Unknown, - ).targetBufferBytes, - ) - assertEquals( - 64 * 1024 * 1024, - PlaybackBufferPolicy.forMode( - PlaybackBufferMode.SmoothPlayback, - PlaybackBufferDeviceProfile.Unknown, - ).targetBufferBytes, + fun `a bigger heap gets a bigger budget than a smaller one`() { + // Proportional scaling means the ceiling grows with the device + // instead of two devices past the old 384 MB tier boundary sharing + // the same flat 160 MiB cap. + val midHeap = PlaybackBufferDeviceProfile(memoryClassMb = 256, isLowRamDevice = false) + val bigHeap = PlaybackBufferDeviceProfile(memoryClassMb = 512, isLowRamDevice = false) + + val midBudget = PlaybackBufferPolicy.memoryBudgetBytes(midHeap) + val bigBudget = PlaybackBufferPolicy.memoryBudgetBytes(bigHeap) + + assertTrue( + bigBudget > midBudget, + "a 512 MB heap ($bigBudget) should get more budget than a 256 MB heap ($midBudget)", ) } @Test - fun roomyDevicesKeepLargeByteCaps() { - assertEquals(128 * 1024 * 1024, PlaybackBufferPolicy.forMode(PlaybackBufferMode.QuickStart, roomyDevice).targetBufferBytes) - assertEquals(160 * 1024 * 1024, PlaybackBufferPolicy.forMode(PlaybackBufferMode.Balanced, roomyDevice).targetBufferBytes) - assertEquals(192 * 1024 * 1024, PlaybackBufferPolicy.forMode(PlaybackBufferMode.SmoothPlayback, roomyDevice).targetBufferBytes) + fun `a low-RAM device with an unknown heap gets the conservative fixed fallback`() { + val unknownHeapLowRam = PlaybackBufferDeviceProfile(memoryClassMb = 0, isLowRamDevice = true) + + assertEquals(24 * 1024 * 1024, PlaybackBufferPolicy.memoryBudgetBytes(unknownHeapLowRam)) } @Test - fun bitrateAwareTargetScalesLowBitrateStreamsBelowDeviceCap() { + fun `a low-RAM device with a small known heap gets the smaller of the flat fallback and its proportional share`() { + // A low-RAM stick reporting a small but genuinely known memoryClass + // must not have that number thrown away in favor of the flat 24 MiB + // fallback — that would be the exact flaw (a fixed value ignoring + // what the device actually reports) the proportional rule exists to + // remove. 48MB is a real memoryClass a low-RAM device could report; + // half of it (24 MiB) ties the flat fallback, so use a heap small + // enough that the proportional share is strictly smaller. + val smallKnownHeapLowRam = + PlaybackBufferDeviceProfile(memoryClassMb = 32, isLowRamDevice = true) + val proportionalBytes = (32 * 1024 * 1024) / 2 + + assertTrue(proportionalBytes < 24 * 1024 * 1024, "test heap must undercut the flat fallback") assertEquals( - 35_937_500, - calculateBitrateTargetBufferBytes( - selectedBitrateBps = 5_000_000, - desiredForwardBufferMs = 50_000, - minimumBytes = 16 * 1024 * 1024, - maximumBytes = 160 * 1024 * 1024, - unknownBitrateFallbackBytes = 96 * 1024 * 1024, - ), + proportionalBytes, + PlaybackBufferPolicy.memoryBudgetBytes(smallKnownHeapLowRam), ) } @Test - fun bitrateAwareTargetClampsHighBitrateRemuxesToDeviceCap() { + fun `a low-RAM device with a larger known heap is still capped at the flat fallback`() { + // The flat 24 MiB fallback must still act as a ceiling on the + // low-RAM path: a low-RAM device reporting a heap large enough that + // half of it exceeds 24 MiB must not get more than the conservative + // fallback just because isLowRamDevice happened to be paired with a + // roomier-looking memoryClass. + val largerKnownHeapLowRam = + PlaybackBufferDeviceProfile(memoryClassMb = 96, isLowRamDevice = true) + assertEquals( - 160 * 1024 * 1024, - calculateBitrateTargetBufferBytes( - selectedBitrateBps = 100_000_000, - desiredForwardBufferMs = 50_000, - minimumBytes = 16 * 1024 * 1024, - maximumBytes = 160 * 1024 * 1024, - unknownBitrateFallbackBytes = 96 * 1024 * 1024, - ), + 24 * 1024 * 1024, + PlaybackBufferPolicy.memoryBudgetBytes(largerKnownHeapLowRam), ) } - private companion object { - val roomyDevice = PlaybackBufferDeviceProfile(memoryClassMb = 384, isLowRamDevice = false) + @Test + fun `constructing a policy with a wider idle window than MAX_LOAD_IDLE_MS throws`() { + assertFailsWith { + PlaybackBufferPolicy( + minBufferMs = 50_000, + maxBufferMs = 120_000, + bufferForPlaybackMs = 2_000, + bufferForPlaybackAfterRebufferMs = 5_000, + targetBufferBytes = 16 * 1024 * 1024, + prioritizeTimeOverSizeThresholds = false, + ) + } } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleTest.kt index 6553438aa..fc85a38f5 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleTest.kt @@ -352,6 +352,100 @@ class PlaybackSessionLifecycleTest { assertEquals(listOf("sess-old"), stopped) } + @Test + fun `external session finalization returns before reporting finishes and stops afterward`() = runTest { + val reportEntered = CompletableDeferred() + val releaseReport = CompletableDeferred() + val stopCompleted = CompletableDeferred() + val calls = mutableListOf() + val sessionMgr = object : FakeSessionManager() { + override suspend fun reportProgress( + sessionId: String, + position: Double, + isPaused: Boolean, + ): ApiResult { + calls += "report:$sessionId:$position:$isPaused" + reportEntered.complete(Unit) + releaseReport.await() + return ApiResult.Success(Unit) + } + + override suspend fun stopSession(sessionId: String): ApiResult { + calls += "stop:$sessionId" + stopCompleted.complete(Unit) + return ApiResult.Success(Unit) + } + } + val lifecycle = newLifecycle(sessionMgr, scope = backgroundScope) + + lifecycle.reportAndStopExternalSessionAsync( + sessionId = "audiobook-session", + positionSeconds = 42.25, + isPaused = true, + ) + + reportEntered.await() + assertEquals( + listOf("report:audiobook-session:42.25:true"), + calls, + "the non-blocking caller must return while progress reporting is suspended", + ) + + releaseReport.complete(Unit) + stopCompleted.await() + + assertEquals( + listOf( + "report:audiobook-session:42.25:true", + "stop:audiobook-session", + ), + calls, + ) + } + + @Test + fun `duplicate external session finalization is coalesced`() = runTest { + val reportEntered = CompletableDeferred() + val releaseReport = CompletableDeferred() + val stopCompleted = CompletableDeferred() + val sessionMgr = object : FakeSessionManager() { + override suspend fun reportProgress( + sessionId: String, + position: Double, + isPaused: Boolean, + ): ApiResult { + progressCallCount++ + reportEntered.complete(Unit) + releaseReport.await() + return ApiResult.Success(Unit) + } + + override suspend fun stopSession(sessionId: String): ApiResult { + val result = super.stopSession(sessionId) + stopCompleted.complete(Unit) + return result + } + } + val lifecycle = newLifecycle(sessionMgr, scope = backgroundScope) + + repeat(2) { + lifecycle.reportAndStopExternalSessionAsync( + sessionId = "audiobook-session", + positionSeconds = 42.25, + isPaused = true, + ) + } + + reportEntered.await() + assertEquals(1, sessionMgr.progressCallCount) + + releaseReport.complete(Unit) + stopCompleted.await() + + assertEquals(1, sessionMgr.progressCallCount) + assertEquals(1, sessionMgr.stopCallCount) + } + @Test fun `cancelled adoption closes the allocated server session`() = runTest { val oldStopEntered = CompletableDeferred() diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControlTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControlTest.kt new file mode 100644 index 000000000..cdb97450e --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControlTest.kt @@ -0,0 +1,269 @@ +package org.prairieserver.prairie.common.player + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PrairieLoadControlTest { + @Test + fun `average bitrate takes precedence over peak bitrate`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates( + averageBitrateBps = 4_000_000, + peakBitrateBps = 9_000_000, + latestNetworkEstimateBps = 100_000_000L, + ), + ), + ) + + assertEquals(4_000_000L, selected) + } + + @Test + fun `peak bitrate is used when average bitrate is invalid`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates( + averageBitrateBps = -1, + peakBitrateBps = 9_000_000, + latestNetworkEstimateBps = 100_000_000L, + ), + ), + ) + + assertEquals(9_000_000L, selected) + } + + @Test + fun `known selected media bitrates are summed and network capacity is ignored`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates(4_000_000, 8_000_000, 100_000_000L), + BufferSizingTrackBitrates(-1, 192_000, 100_000_000L), + ), + ) + + assertEquals(4_192_000L, selected) + } + + @Test + fun `one known media rate suppresses network fallback from metadata-poor tracks`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates(4_000_000, 8_000_000, 100_000_000L), + BufferSizingTrackBitrates(-1, -1, 100_000_000L), + ), + ) + + assertEquals(4_000_000L, selected) + } + + @Test + fun `largest network estimate is the last resort when all media metadata is invalid`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates(0, -1, 18_000_000L), + BufferSizingTrackBitrates(-1, 0, 25_000_000L), + ), + ) + + assertEquals(25_000_000L, selected) + } + + @Test + fun `unknown bitrate remains unknown when metadata and network estimates are invalid`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates(-1, 0, -1L), + ), + ) + + assertNull(selected) + } + + @Test + fun `empty track selection remains unknown`() { + assertNull(selectBufferSizingBitrateBps(emptyList())) + } + + @Test + fun `depth follows the budget honestly, even below the floor`() { + // 60 Mbps against a 48 MiB budget: accounting for the same 15% + // overhead margin calculateBitrateTargetBufferBytes applies when it + // turns this depth back into bytes, the budget only really affords + // ~5.8s. The requested 180s cannot be held, and neither can the 20s + // floor — the budget wins over the floor because a false, rounded-up + // report would be worse than an honest shortfall. + val depth = + affordableDepthMs( + desiredDepthMs = 180_000, + selectedBitrateBps = 60_000_000L, + budgetBytes = 48 * 1024 * 1024, + minimumDepthMs = 20_000, + ) + + assertTrue("expected reduction below the floor, got $depth", depth < 20_000) + assertEquals("should report the honest budget-derived value", 5_835, depth) + } + + @Test + fun `depth is left alone when the budget can fund it`() { + // 5 Mbps against 160 MiB: ~268s available, more than the 180s asked for. + val depth = + affordableDepthMs( + desiredDepthMs = 180_000, + selectedBitrateBps = 5_000_000L, + budgetBytes = 160 * 1024 * 1024, + minimumDepthMs = 20_000, + ) + + assertEquals(180_000, depth) + } + + @Test + fun `depth falls back to the request when the bitrate is unknown`() { + val depth = + affordableDepthMs( + desiredDepthMs = 120_000, + selectedBitrateBps = null, + budgetBytes = 96 * 1024 * 1024, + minimumDepthMs = 20_000, + ) + + assertEquals(120_000, depth) + } + + @Test + fun `composed sizing derives depth from the budget and sizes bytes just under the ceiling`() { + // A 40 Mbps stream on a 48 MiB budget cannot hold the 180s the policy + // asks for; the budget-derived depth (~8.75s, once the overhead + // margin is accounted for) is neither the request nor the 20s floor. + // The resulting byte target is sized from that depth and lands at or + // just under the budget — not the (distinct) fallback — which is + // exactly what proves the depth actually determines the bytes, + // rather than both overshooting and clamping to the same ceiling + // regardless of which depth was used. + val budgetBytes = 48 * 1024 * 1024 + val fallbackBytes = 30 * 1024 * 1024 // distinct from budgetBytes: catches a maximumBytes mix-up + val result = + computeBufferSizing( + selectedBitrateBps = 40_000_000L, + desiredDepthMs = PlaybackBufferPolicy.MAX_DEPTH_MS, + minimumDepthMs = PlaybackBufferPolicy.MIN_DEPTH_MS, + budgetBytes = budgetBytes, + minimumBytes = PrairieLoadControl.MIN_TARGET_BUFFER_BYTES, + unknownBitrateFallbackBytes = fallbackBytes, + ) + + assertEquals("depth should be the honest budget-derived value", 8_753, result.depth.ms) + assertTrue( + "byte target ${result.target.bytes} should not exceed the budget $budgetBytes", + result.target.bytes <= budgetBytes, + ) + assertTrue( + "byte target ${result.target.bytes} should land just under the budget, not clamp to it", + result.target.bytes > budgetBytes - (budgetBytes / 50), + ) + } + + @Test + fun `composed sizing reports the true budget-limited depth, not just a clamped byte target`() { + // Both a correctly-routed depth and an un-routed, un-reduced one can + // produce the same clamped byte target once the byte clamp is hit — + // that erasure is exactly how a wiring bug that never routes the + // affordable depth into the byte calculation went undetected. This + // asserts the depth itself, which is the only place such a bug is + // visible: 60 Mbps against a 48 MiB budget affords ~5.8s once the + // overhead margin is accounted for. + val result = + computeBufferSizing( + selectedBitrateBps = 60_000_000L, + desiredDepthMs = PlaybackBufferPolicy.MAX_DEPTH_MS, + minimumDepthMs = PlaybackBufferPolicy.MIN_DEPTH_MS, + budgetBytes = 48 * 1024 * 1024, + minimumBytes = PrairieLoadControl.MIN_TARGET_BUFFER_BYTES, + unknownBitrateFallbackBytes = 48 * 1024 * 1024, + ) + + assertEquals("depth should be the true budget-limited value", 5_835, result.depth.ms) + } + + @Test + fun `composed sizing routes the fallback bytes when the bitrate is unknown`() { + // With no bitrate to size from, the requested depth passes through + // untouched and the byte target must come from the caller-supplied + // fallback (what the superclass computed) rather than the budget. + val fallbackBytes = 40 * 1024 * 1024 + val result = + computeBufferSizing( + selectedBitrateBps = null, + desiredDepthMs = 120_000, + minimumDepthMs = PlaybackBufferPolicy.MIN_DEPTH_MS, + budgetBytes = 160 * 1024 * 1024, + minimumBytes = PrairieLoadControl.MIN_TARGET_BUFFER_BYTES, + unknownBitrateFallbackBytes = fallbackBytes, + ) + + assertEquals("depth should pass through unchanged", 120_000, result.depth.ms) + assertEquals("byte target should route the fallback", fallbackBytes, result.target.bytes) + } + + @Test + fun `composed sizing never asks for a deeper buffer than the policy requested`() { + // A reduction must only ever shrink the depth, never grow it — + // growing it would widen the fixed idle window between min and max. + val desiredDepthMs = PlaybackBufferPolicy.MAX_DEPTH_MS + val result = + computeBufferSizing( + selectedBitrateBps = 80_000_000L, + desiredDepthMs = desiredDepthMs, + minimumDepthMs = PlaybackBufferPolicy.MIN_DEPTH_MS, + budgetBytes = 48 * 1024 * 1024, + minimumBytes = PrairieLoadControl.MIN_TARGET_BUFFER_BYTES, + unknownBitrateFallbackBytes = 48 * 1024 * 1024, + ) + + assertTrue( + "depth ${result.depth.ms} exceeded requested $desiredDepthMs", + result.depth.ms <= desiredDepthMs, + ) + } + + @Test + fun `composed sizing keeps the budget authoritative when it is below the nominal byte floor`() { + // MIN_TARGET_BUFFER_BYTES and the policy's memory floor are both + // 16 MiB today, so nothing on shipping hardware reaches this case. + // A future change to either constant could separate them, and the + // relation must then be restored by lowering the floor, not raising + // the ceiling: a device allowed 8 MiB must get 8 MiB, not the 16 MiB + // floor its heap cannot hold. Both a known and an unknown bitrate are + // exercised, since the unknown path routes a caller-supplied fallback + // that is itself larger than the budget here. + val budgetBytes = 8 * 1024 * 1024 + + for (bitrate in listOf(6_000_000L, null)) { + val result = + computeBufferSizing( + selectedBitrateBps = bitrate, + desiredDepthMs = PlaybackBufferPolicy.MAX_DEPTH_MS, + minimumDepthMs = PlaybackBufferPolicy.MIN_DEPTH_MS, + budgetBytes = budgetBytes, + minimumBytes = PrairieLoadControl.MIN_TARGET_BUFFER_BYTES, + unknownBitrateFallbackBytes = 40 * 1024 * 1024, + ) + + assertTrue( + "byte target ${result.target.bytes} exceeded the budget $budgetBytes at bitrate $bitrate", + result.target.bytes <= budgetBytes, + ) + } + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt index 78623ba4b..c3fb3ead0 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt @@ -1,19 +1,29 @@ package org.prairieserver.prairie.common.player +import android.app.Activity +import android.os.Looper +import android.view.View +import android.widget.FrameLayout import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.CaptionStyleCompat +import androidx.media3.ui.PlayerView import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.settings.SubtitleBackgroundStylePreset import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset +import org.prairieserver.prairie.model.settings.SubtitlePositionPreset import org.junit.runner.RunWith +import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config import kotlin.test.Test import kotlin.test.assertEquals @OptIn(UnstableApi::class) @RunWith(RobolectricTestRunner::class) +@Config(qualifiers = "w1920dp-h1080dp-mdpi") class SubtitleManagerAppearanceTest { @Test @@ -28,25 +38,48 @@ class SubtitleManagerAppearanceTest { } @Test - fun subtitleTextFractionsMatchTheWebScale() { + fun phoneSubtitleTextFractionsAreOneEighthLarger() { + val manager = SubtitleManager( + presentation = AndroidSubtitlePresentation.Phone, + ) + + assertEquals(22.5f / 720f, fractionalSize(manager, SubtitleFontSizePreset.Small)) + assertEquals(29.25f / 720f, fractionalSize(manager, SubtitleFontSizePreset.Medium)) + assertEquals(36f / 720f, fractionalSize(manager, SubtitleFontSizePreset.Large)) + assertEquals(45f / 720f, fractionalSize(manager, SubtitleFontSizePreset.XLarge)) + assertEquals(54f / 720f, fractionalSize(manager, SubtitleFontSizePreset.XXLarge)) + } + + @Test + fun televisionSubtitleTextFractionsPreserveExistingScale() { + val manager = SubtitleManager( + presentation = AndroidSubtitlePresentation.Television, + ) + + assertEquals(20f / 720f, fractionalSize(manager, SubtitleFontSizePreset.Small)) + assertEquals(26f / 720f, fractionalSize(manager, SubtitleFontSizePreset.Medium)) + assertEquals(32f / 720f, fractionalSize(manager, SubtitleFontSizePreset.Large)) + assertEquals(40f / 720f, fractionalSize(manager, SubtitleFontSizePreset.XLarge)) + assertEquals(48f / 720f, fractionalSize(manager, SubtitleFontSizePreset.XXLarge)) + } + + private fun fractionalSize( + manager: SubtitleManager, + preset: SubtitleFontSizePreset, + ): Float { val method = SubtitleManager::class.java.getDeclaredMethod( "fractionalSizeFor", SubtitleFontSizePreset::class.java, ) method.isAccessible = true - - assertEquals(20f / 720f, method.invoke(SubtitleManager(), SubtitleFontSizePreset.Small) as Float) - assertEquals(26f / 720f, method.invoke(SubtitleManager(), SubtitleFontSizePreset.Medium) as Float) - assertEquals(32f / 720f, method.invoke(SubtitleManager(), SubtitleFontSizePreset.Large) as Float) - assertEquals(40f / 720f, method.invoke(SubtitleManager(), SubtitleFontSizePreset.XLarge) as Float) - assertEquals(48f / 720f, method.invoke(SubtitleManager(), SubtitleFontSizePreset.XXLarge) as Float) + return method.invoke(manager, preset) as Float } @Test fun bottomSubtitlesUseTheReferenceSafeMargin() { val method = SubtitleManager::class.java.getDeclaredMethod( "bottomPaddingFor", - org.prairieserver.prairie.model.settings.SubtitlePositionPreset::class.java, + SubtitlePositionPreset::class.java, ) method.isAccessible = true @@ -54,11 +87,42 @@ class SubtitleManagerAppearanceTest { 0.09f, method.invoke( SubtitleManager(), - org.prairieserver.prairie.model.settings.SubtitlePositionPreset.Bottom, + SubtitlePositionPreset.Bottom, ) as Float, ) } + @Test + fun titleSafeCompensationDoesNotDoubleShiftTopSubtitles() { + val manager = SubtitleManager().apply { + titleSafeFraction = 0.05f + } + val method = SubtitleManager::class.java.getDeclaredMethod( + "bottomPaddingFor", + SubtitlePositionPreset::class.java, + ) + method.isAccessible = true + + // The padding fraction is evaluated inside a surface scaled to 90% of + // the original video height. Preserve the original physical presets: + // f + p(1 - 2f) = base, so p = (base - f) / (1 - 2f). + assertEquals( + expected = (0.74f - 0.05f) / 0.90f, + actual = method.invoke(manager, SubtitlePositionPreset.Top) as Float, + absoluteTolerance = 0.0001f, + ) + assertEquals( + expected = (0.09f - 0.05f) / 0.90f, + actual = method.invoke(manager, SubtitlePositionPreset.Bottom) as Float, + absoluteTolerance = 0.0001f, + ) + assertEquals( + expected = (0.18f - 0.05f) / 0.90f, + actual = method.invoke(manager, SubtitlePositionPreset.LowerThird) as Float, + absoluteTolerance = 0.0001f, + ) + } + @Test fun boxBackgroundStyleAppliesConfiguredBackgroundAlpha() { val style = captionStyleFor( @@ -168,6 +232,101 @@ class SubtitleManagerAppearanceTest { assertEquals(SubtitleVideoRect(left = 0, top = 0, width = 1080, height = 2400), fill) } + @Test + fun zoomIgnoresStaleFittedContentFrameAndUsesFullViewport() { + val staleFit = SubtitleVideoRect(left = 0, top = 236, width = 2404, height = 1352) + val fullViewport = SubtitleVideoRect(left = 0, top = 0, width = 2404, height = 1080) + + assertEquals( + fullViewport, + selectSubtitleCanvasRect( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + contentFrameRect = staleFit, + displayedVideoRect = fullViewport, + ), + ) + } + + @Test + fun zoomUsesVisibleViewportInNegativeContentFrameParentCoordinates() { + val visibleCanvas = requireNotNull( + displayedSubtitleContentFrameRect( + viewWidth = 1920, + viewHeight = 1080, + frameLeft = -120, + frameTop = -64, + frameWidth = 2160, + frameHeight = 1208, + ), + ) + val fullViewport = SubtitleVideoRect(left = 0, top = 0, width = 1920, height = 1080) + + assertEquals( + SubtitleVideoRect(left = 120, top = 64, width = 1920, height = 1080), + selectSubtitleCanvasRect( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + contentFrameRect = visibleCanvas, + displayedVideoRect = fullViewport, + ), + ) + } + + @Test + fun stretchIgnoresStaleFittedContentFrameAndUsesFullViewport() { + val staleFit = SubtitleVideoRect(left = 240, top = 0, width = 1920, height = 1080) + val fullViewport = SubtitleVideoRect(left = 0, top = 0, width = 2400, height = 1080) + + assertEquals( + fullViewport, + selectSubtitleCanvasRect( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL, + contentFrameRect = staleFit, + displayedVideoRect = fullViewport, + ), + ) + } + + @Test + fun fitContinuesToUsePostLayoutContentFrame() { + val fittedFrame = SubtitleVideoRect(left = 0, top = 0, width = 1920, height = 1080) + val computedFallback = SubtitleVideoRect(left = 240, top = 0, width = 1920, height = 1080) + + assertEquals( + fittedFrame, + selectSubtitleCanvasRect( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT, + contentFrameRect = fittedFrame, + displayedVideoRect = computedFallback, + ), + ) + } + + @Test + fun repeatedModeSelectionDoesNotRetainPreviousCanvas() { + val fit = SubtitleVideoRect(left = 240, top = 0, width = 1920, height = 1080) + val full = SubtitleVideoRect(left = 0, top = 0, width = 2400, height = 1080) + + val fill = selectSubtitleCanvasRect( + AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + fit, + full, + ) + val stretch = selectSubtitleCanvasRect( + AspectRatioFrameLayout.RESIZE_MODE_FILL, + fit, + full, + ) + val restoredFit = selectSubtitleCanvasRect( + AspectRatioFrameLayout.RESIZE_MODE_FIT, + fit, + fit, + ) + + assertEquals(full, fill) + assertEquals(full, stretch) + assertEquals(fit, restoredFit) + } + @Test fun invalidVideoSizeUsesFullViewRect() { val rect = displayedSubtitleVideoRect( @@ -224,6 +383,145 @@ class SubtitleManagerAppearanceTest { ) } + @Test + fun mountedCanvasReconcilesFitToZoomAfterContentFrameLayout() { + val canvas = MountedSubtitleCanvas() + + assertEquals(SubtitleVideoRect(0, 0, 1440, 1016), canvas.subtitleRect()) + + canvas.transition( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + frame = FrameBounds(-120, -64, 2040, 1080), + ) + + assertEquals(SubtitleVideoRect(120, 64, 1920, 1016), canvas.subtitleRect()) + } + + @Test + fun mountedCanvasReconcilesFitToFillAfterContentFrameLayout() { + val canvas = MountedSubtitleCanvas() + + canvas.transition( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL, + frame = FrameBounds(0, 0, 1920, 1016), + ) + + assertEquals(SubtitleVideoRect(0, 0, 1920, 1016), canvas.subtitleRect()) + } + + @Test + fun mountedCanvasDoesNotRetainOffsetsAcrossRepeatedZoomAndFillSwitches() { + val canvas = MountedSubtitleCanvas() + + canvas.transition( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + frame = FrameBounds(-120, -64, 2040, 1080), + ) + assertEquals(SubtitleVideoRect(120, 64, 1920, 1016), canvas.subtitleRect()) + + canvas.transition( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL, + frame = FrameBounds(0, 0, 1920, 1016), + ) + assertEquals(SubtitleVideoRect(0, 0, 1920, 1016), canvas.subtitleRect()) + + canvas.transition( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + frame = FrameBounds(-120, -64, 2040, 1080), + ) + assertEquals(SubtitleVideoRect(120, 64, 1920, 1016), canvas.subtitleRect()) + } + + @Test + fun mountedCanvasReconcilesZoomBackToFitAfterContentFrameLayout() { + val canvas = MountedSubtitleCanvas() + canvas.transition( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + frame = FrameBounds(-120, -64, 2040, 1080), + ) + + canvas.transition( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT, + frame = FrameBounds(240, 0, 1680, 1016), + ) + + assertEquals(SubtitleVideoRect(0, 0, 1440, 1016), canvas.subtitleRect()) + } + + @Test + fun mountedCanvasCorrectsFillToFitWhenFirstPreDrawSeesCroppedFrame() { + val canvas = MountedSubtitleCanvas() + canvas.transition( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + frame = FrameBounds(-120, -64, 2040, 1080), + ) + + canvas.transitionAfterEarlyPreDraw( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT, + finalFrame = FrameBounds(240, 0, 1680, 1016), + ) + + assertEquals(SubtitleVideoRect(0, 0, 1440, 1016), canvas.subtitleRect()) + } + + @Test + fun rapidEarlyTransitionsApplyOnlyLatestMode() { + val canvas = MountedSubtitleCanvas() + canvas.schedule(AspectRatioFrameLayout.RESIZE_MODE_ZOOM) + canvas.schedule(AspectRatioFrameLayout.RESIZE_MODE_FILL) + canvas.schedule(AspectRatioFrameLayout.RESIZE_MODE_FIT) + canvas.dispatchEarlyPreDrawThenMount(FrameBounds(240, 0, 1680, 1016)) + + assertEquals(SubtitleVideoRect(0, 0, 1440, 1016), canvas.subtitleRect()) + assertEquals(2, canvas.reconciliationCount) + } + + @Test + fun detachCancelsPostedSnapshotVerification() { + val canvas = MountedSubtitleCanvas() + canvas.schedule(AspectRatioFrameLayout.RESIZE_MODE_ZOOM) + canvas.dispatchPreDraw() + canvas.detach() + canvas.mountFrameAndDrain(FrameBounds(-120, -64, 2040, 1080)) + + assertEquals(1, canvas.reconciliationCount) + } + + @Test + fun detachedPendingReconciliationLeavesSubtitleLayoutSentinelUnchanged() { + val canvas = MountedSubtitleCanvas() + + canvas.schedule(AspectRatioFrameLayout.RESIZE_MODE_ZOOM) + val sentinel = SubtitleVideoRect(33, 44, 777, 555) + canvas.setSubtitleRect(sentinel) + canvas.detachAndDrain(FrameBounds(-120, -64, 2040, 1080)) + + assertEquals(sentinel, canvas.subtitleRect()) + } + + @Test + fun repeatedExplicitSyncsRunOnePostLayoutReconciliation() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val playerView = PlayerView(activity) + val manager = SubtitleManager() + activity.setContentView(playerView) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + manager.syncSubtitleVideoBounds(playerView) + playerView.viewTreeObserver.dispatchOnPreDraw() + + var reconciliations = 0 + manager.postLayoutReconciliationObserver = { reconciliations++ } + repeat(3) { + manager.syncSubtitleVideoBounds(playerView) + } + + playerView.viewTreeObserver.dispatchOnPreDraw() + playerView.viewTreeObserver.dispatchOnPreDraw() + + assertEquals(1, reconciliations) + } + private fun captionStyleFor(appearance: SubtitleAppearance): CaptionStyleCompat { val method = SubtitleManager::class.java.getDeclaredMethod( "buildCaptionStyle", @@ -233,3 +531,152 @@ class SubtitleManagerAppearanceTest { return method.invoke(SubtitleManager(), appearance) as CaptionStyleCompat } } + +private fun SubtitleManager.subtitleRectSyncForTest(playerView: PlayerView): Any { + val field = SubtitleManager::class.java.getDeclaredField("videoRectSyncs") + field.isAccessible = true + val syncs = field.get(this) as Map<*, *> + return requireNotNull(syncs[playerView]) +} + +private data class FrameBounds( + val left: Int, + val top: Int, + val right: Int, + val bottom: Int, +) + +private class MountedSubtitleCanvas { + private val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + private val playerView = PlayerView(activity) + private val contentFrame = requireNotNull( + playerView.findViewById( + androidx.media3.ui.R.id.exo_content_frame, + ), + ) + private val subtitleView = requireNotNull(playerView.subtitleView) + private val manager = SubtitleManager() + var reconciliationCount = 0 + private set + + init { + activity.setContentView(playerView) + Shadows.shadowOf(Looper.getMainLooper()).idle() + check(playerView.width == 1920 && playerView.height == 1016) + playerView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + manager.syncSubtitleVideoBounds(playerView) + drainScheduledWork() + + // Isolate the explicit post-layout reconciliation from the permanent + // layout listeners: production has both, but this harness proves the + // bounded fallback still works when an early callback runs before the + // content-frame traversal that supplies the final geometry. + val syncListener = + manager.subtitleRectSyncForTest(playerView) as View.OnLayoutChangeListener + playerView.removeOnLayoutChangeListener(syncListener) + contentFrame.removeOnLayoutChangeListener(syncListener) + contentFrame.layout(240, 0, 1680, 1016) + manager.syncSubtitleVideoBounds(playerView) + manager.postLayoutReconciliationObserver = { reconciliationCount++ } + } + + fun schedule(resizeMode: Int) { + playerView.resizeMode = resizeMode + manager.syncSubtitleVideoBounds(playerView) + } + + fun transition(resizeMode: Int, frame: FrameBounds) { + schedule(resizeMode) + contentFrame.layout(frame.left, frame.top, frame.right, frame.bottom) + playerView.viewTreeObserver.dispatchOnPreDraw() + } + + fun transitionAfterEarlyPreDraw(resizeMode: Int, finalFrame: FrameBounds) { + schedule(resizeMode) + playerView.viewTreeObserver.dispatchOnPreDraw() + contentFrame.layout( + finalFrame.left, + finalFrame.top, + finalFrame.right, + finalFrame.bottom, + ) + Shadows.shadowOf(Looper.getMainLooper()).idle() + // Robolectric's parent traversal has no renderer-backed aspect ratio, + // so re-mount the observed Media3 frame before the corrective pre-draw. + contentFrame.layout( + finalFrame.left, + finalFrame.top, + finalFrame.right, + finalFrame.bottom, + ) + playerView.viewTreeObserver.dispatchOnPreDraw() + } + + fun dispatchEarlyPreDrawThenMount(finalFrame: FrameBounds) { + contentFrame.layout(-120, -64, 2040, 1080) + playerView.viewTreeObserver.dispatchOnPreDraw() + contentFrame.layout( + finalFrame.left, + finalFrame.top, + finalFrame.right, + finalFrame.bottom, + ) + Shadows.shadowOf(Looper.getMainLooper()).idle() + // Keep the synthetic final frame mounted after Robolectric drains the + // posted verifier and its unrelated full-width parent traversal. + contentFrame.layout( + finalFrame.left, + finalFrame.top, + finalFrame.right, + finalFrame.bottom, + ) + playerView.viewTreeObserver.dispatchOnPreDraw() + } + + fun dispatchPreDraw() { + playerView.viewTreeObserver.dispatchOnPreDraw() + } + + fun detach() { + activity.setContentView(FrameLayout(activity)) + } + + fun mountFrameAndDrain(frame: FrameBounds) { + contentFrame.layout(frame.left, frame.top, frame.right, frame.bottom) + Shadows.shadowOf(Looper.getMainLooper()).idle() + if (playerView.viewTreeObserver.isAlive) { + playerView.viewTreeObserver.dispatchOnPreDraw() + } + } + + fun detachAndDrain(frame: FrameBounds) { + detach() + mountFrameAndDrain(frame) + } + + fun subtitleRect(): SubtitleVideoRect { + val params = subtitleView.layoutParams as FrameLayout.LayoutParams + return SubtitleVideoRect( + left = params.leftMargin, + top = params.topMargin, + width = params.width, + height = params.height, + ) + } + + fun setSubtitleRect(rect: SubtitleVideoRect) { + val params = subtitleView.layoutParams as FrameLayout.LayoutParams + params.leftMargin = rect.left + params.topMargin = rect.top + params.width = rect.width + params.height = rect.height + subtitleView.layoutParams = params + } + + private fun drainScheduledWork() { + if (playerView.viewTreeObserver.isAlive) { + playerView.viewTreeObserver.dispatchOnPreDraw() + } + Shadows.shadowOf(Looper.getMainLooper()).idle() + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStoreTest.kt index e68e78341..920fa3f5d 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStoreTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStoreTest.kt @@ -3,10 +3,15 @@ package org.prairieserver.prairie.common.settings import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences -import org.prairieserver.prairie.model.settings.EffectiveSetting -import org.prairieserver.prairie.model.settings.EffectiveSettingsResponse +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import org.prairieserver.prairie.model.settings.EffectiveSettingValue +import org.prairieserver.prairie.model.settings.EffectiveSettingValuesResponse import org.prairieserver.prairie.model.settings.EffectiveSubtitleAppearance import org.prairieserver.prairie.model.settings.PlaybackSettingsKeys +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.model.settings.SettingScope import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset import org.prairieserver.prairie.network.ApiResult @@ -18,6 +23,10 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive import org.junit.Before import org.junit.Rule import org.junit.rules.TemporaryFolder @@ -69,6 +78,34 @@ class AndroidPlayerSettingsStoreTest { ) } + /** + * A store whose backing DataStore already holds [seed]. + * + * The seeding goes through the SAME DataStore instance the store will use — + * DataStore refuses two live instances over one file, so writing the + * fixture through a second handle throws rather than setting up the state + * under test. + */ + private suspend fun newStoreSeededWith( + seed: suspend (androidx.datastore.preferences.core.MutablePreferences) -> Unit, + ): AndroidPlayerSettingsStore { + val shared = PreferenceDataStoreFactory.create( + produceFile = { File(tempFolder.root, "ds_seeded.preferences_pb") }, + ) + shared.edit { prefs -> seed(prefs) } + return AndroidPlayerSettingsStore( + context = mockContextStub(), + legacyCache = fakeLegacyCache, + getActiveProfileId = { activeProfileId }, + getServerUrl = { serverUrl }, + serverSettingsFlusher = fakeFlusher, + scope = TestScope(), + profileChangeSignal = flowOf(Unit), + getDeviceId = { null }, + dataStoreFactory = { shared }, + ) + } + @Test fun `setAutoSkipIntro updates flow value`() = runTest { val store = newStore() @@ -233,18 +270,33 @@ class AndroidPlayerSettingsStoreTest { assertEquals(-10000, store.subtitleSyncMsFlow.first()) } + @Test + fun `subtitleSyncMs writes the canonical remote device setting`() = runTest { + val store = newStore() + + store.setSubtitleSyncMs(-1900) + + assertEquals(-1900, store.subtitleSyncMsFlow.first()) + val call = fakeFlusher.calls.last() + assertEquals(activeProfileId, call.profileId) + assertEquals(PlaybackSettingsKeys.SubtitleSyncMs, call.key) + assertEquals("-1900", call.value) + assertEquals(serverUrl, call.serverUrl) + assertFalse(call.isDelete) + } + // ---- Server-sync surface ------------------------------------------ @Test - fun `refreshFromServer populates flows from effective settings response`() = runTest { + fun `refreshFromServer populates flows from batched effective values`() = runTest { val repo = SettingsRepository( FakeSettingsApi( effective = mapOf( - PlaybackSettingsKeys.AutoSkipIntro to "true", - PlaybackSettingsKeys.AutoPlayNext to "false", - PlaybackSettingsKeys.PreferredQuality to "1080p", - PlaybackSettingsKeys.AudioSyncMs to "120", - PlaybackSettingsKeys.PlaybackSpeed to "1.5", + stored(PlaybackSettingsKeys.AutoSkipIntro, JsonPrimitive(true)), + stored(PlaybackSettingsKeys.AutoPlayNext, JsonPrimitive(false)), + stored(PlaybackSettingsKeys.PreferredQuality, JsonPrimitive("1080p")), + stored(PlaybackSettingsKeys.AudioSyncMs, JsonPrimitive(120)), + stored(PlaybackSettingsKeys.PlaybackSpeed, JsonPrimitive(1.5)), ), ), ) @@ -257,6 +309,70 @@ class AndroidPlayerSettingsStoreTest { assertEquals(1.5, store.playbackSpeedFlow.first(), 0.0) } + @Test + fun `refreshFromServer requests only server-stored keys`() = runTest { + val api = FakeSettingsApi() + val store = newStore(repository = SettingsRepository(api)) + store.refreshFromServer() + val remote = SettingKeys.REMOTE.toSet() + assertTrue(api.requestedKeys.isNotEmpty()) + assertTrue( + api.requestedKeys.all { it in remote }, + "asked for non-contract keys: ${api.requestedKeys.filterNot { it in remote }}", + ) + } + + @Test + fun `refreshFromServer applies contract defaults over stale local overrides`() = runTest { + // The canonical endpoint answers every known key; one nothing is + // stored for comes back as the contract default with source + // "default". A stale local override (say the server-side value was + // reset from another device) must hydrate back to that default + // rather than surviving locally. + val api = FakeSettingsApi( + effective = mapOf( + defaulted(PlaybackSettingsKeys.AutoSkipIntro, JsonPrimitive(false)), + defaulted(PlaybackSettingsKeys.NextUpPromptSeconds, JsonPrimitive(30)), + ), + ) + val store = newStore(repository = SettingsRepository(api)) + store.setAutoSkipIntro(true) + store.setNextUpPromptSeconds(90) + + store.refreshFromServer() + + assertEquals(false, store.autoSkipIntroFlow.first()) + assertEquals(30, store.nextUpPromptSecondsFlow.first()) + } + + @Test + fun `refreshFromServer keeps local value for keys this server does not know`() = runTest { + // A key absent from the response entirely means the server's + // contract revision predates it — not that it was reset. + val api = FakeSettingsApi(effective = emptyMap()) + val store = newStore(repository = SettingsRepository(api)) + store.setAutoSkipIntro(true) + + store.refreshFromServer() + + assertEquals(true, store.autoSkipIntroFlow.first()) + } + + @Test + fun `refreshFromServer maps JSON null to the local no-preference spelling`() = runTest { + val api = FakeSettingsApi( + effective = mapOf( + defaulted(PlaybackSettingsKeys.AudioLanguage, JsonNull), + ), + ) + val store = newStore(repository = SettingsRepository(api)) + store.setAudioLanguage("de") + + store.refreshFromServer() + + assertEquals("", store.audioLanguageFlow.first()) + } + @Test fun `refreshFromServer no-ops when repository is null`() = runTest { val store = newStore(repository = null) @@ -269,9 +385,12 @@ class AndroidPlayerSettingsStoreTest { val repo = SettingsRepository( FakeSettingsApi( effective = mapOf( - PlaybackSettingsKeys.SubtitleAppearance to SubtitleAppearance.DEFAULT.toJsonString(), + stored( + PlaybackSettingsKeys.SubtitleAppearance, + Json.parseToJsonElement(SubtitleAppearance.DEFAULT.toJsonString()), + scope = SettingScope.PROFILE_DEVICE.wire, + ), ), - hasDeviceOverride = setOf(PlaybackSettingsKeys.SubtitleAppearance), ), ) val store = newStore(repository = repo) @@ -281,36 +400,52 @@ class AndroidPlayerSettingsStoreTest { } @Test - fun `refreshFromServer clears override flag when subtitle entry absent`() = runTest { - // First refresh: server reports a device override; flag goes true. + fun `refreshFromServer clears override flag when appearance no longer resolves from this device`() = runTest { + // First refresh: the appearance resolves from profile_device; flag + // goes true. val api = FakeSettingsApi( effective = mapOf( - PlaybackSettingsKeys.SubtitleAppearance to SubtitleAppearance.DEFAULT.toJsonString(), + stored( + PlaybackSettingsKeys.SubtitleAppearance, + Json.parseToJsonElement(SubtitleAppearance.DEFAULT.toJsonString()), + scope = SettingScope.PROFILE_DEVICE.wire, + ), ), - hasDeviceOverride = setOf(PlaybackSettingsKeys.SubtitleAppearance), ) val store = newStore(repository = SettingsRepository(api)) store.refreshFromServer() assertTrue(store.subtitleUsesDeviceOverrideFlow.first()) - // Server stops returning the entry — e.g. another device cleared - // the override out-of-band. Flag must go false on the next - // refresh; iOS parity in `applyEffectiveSettings`'s `else` branch. - api.effective = emptyMap() - api.hasDeviceOverride = emptySet() + // Another device cleared the override out-of-band; the value now + // resolves from the contract default. Flag must go false on the + // next refresh; iOS parity in `applyEffectiveSettings`'s `else` + // branch. + api.effective = mapOf( + defaulted( + PlaybackSettingsKeys.SubtitleAppearance, + Json.parseToJsonElement(SubtitleAppearance.DEFAULT.toJsonString()), + ), + ) store.refreshFromServer() assertFalse(store.subtitleUsesDeviceOverrideFlow.first()) } @Test - fun `resetAllDeviceSettings enqueues delete for every device key`() = runTest { + fun `resetAllDeviceSettings enqueues delete for every server-stored device key`() = runTest { val repo = SettingsRepository(FakeSettingsApi()) val store = newStore(repository = repo) store.resetAllDeviceSettings() val deletedKeys = fakeFlusher.calls.filter { it.isDelete }.map { it.key }.toSet() - for (key in PlaybackSettingsKeys.DeviceSettings) { + val remote = SettingKeys.REMOTE.toSet() + for (key in PlaybackSettingsKeys.DeviceSettings.filter { it in remote }) { assertTrue(deletedKeys.contains(key), "expected delete for $key") } + // The granular subtitle.* fields live inside the composite + // playback.subtitle_appearance object; deleting them individually + // would be refused as unknown_setting. + for (key in PlaybackSettingsKeys.DeviceSettings.filterNot { it in remote }) { + assertFalse(deletedKeys.contains(key), "must not delete non-contract key $key") + } } @Test @@ -337,7 +472,13 @@ class AndroidPlayerSettingsStoreTest { val repo = SettingsRepository( FakeSettingsApi( effective = mapOf( - PlaybackSettingsKeys.SubtitleAppearance to fallback.toJsonString(), + // Resolves from the profile scope once the device + // override is deleted. + stored( + PlaybackSettingsKeys.SubtitleAppearance, + Json.parseToJsonElement(fallback.toJsonString()), + scope = SettingScope.PROFILE.wire, + ), ), ), ) @@ -357,6 +498,165 @@ class AndroidPlayerSettingsStoreTest { assertEquals(custom, store.subtitleAppearanceFlow.first()) } + @Test + fun `setQuality writes both axes and flushes them together`() = runTest { + val store = newStore() + store.setQuality("1080p", 10000) + + assertEquals("1080p", store.preferredQualityFlow.first()) + assertEquals(10000, store.maxBitrateKbpsFlow.first()) + val flushed = fakeFlusher.calls.filterNot { it.isDelete }.associate { it.key to it.value } + assertEquals("1080p", flushed[PlaybackSettingsKeys.PreferredQuality]) + assertEquals("10000", flushed[PlaybackSettingsKeys.MaxBitrateKbps]) + } + + @Test + fun `an uncapped preset stores no bitrate`() = runTest { + val store = newStore() + store.setQuality("1080p", 6000) + store.setQuality("original", null) + + // null is uncapped, which the store spells as 0 — outside the + // contract's range, so it can never read back as a real cap. + assertEquals(null, store.maxBitrateKbpsFlow.first()) + assertEquals( + "0", + fakeFlusher.calls.last { it.key == PlaybackSettingsKeys.MaxBitrateKbps }.value, + ) + } + + @Test + fun `a legacy compound quality normalizes on read and on write`() = runTest { + val store = newStore() + // The bitrate a compound value encoded lives on its own axis now; + // handing "1080p-high" to the player or the server would be refused. + store.setPreferredQuality("1080p-high") + + assertEquals("1080p", store.preferredQualityFlow.first()) + assertEquals( + "1080p", + fakeFlusher.calls.last { it.key == PlaybackSettingsKeys.PreferredQuality }.value, + ) + } + + @Test + fun `a refresh resolving no bitrate clears a stale local cap`() = runTest { + val repo = SettingsRepository( + FakeSettingsApi( + effective = mapOf( + stored(PlaybackSettingsKeys.PreferredQuality, JsonPrimitive("720p")), + defaulted(PlaybackSettingsKeys.MaxBitrateKbps, JsonNull), + ), + ), + ) + val store = newStore(repository = repo) + store.setQuality("1080p", 10000) + + store.refreshFromServer() + + assertEquals("720p", store.preferredQualityFlow.first()) + assertEquals( + null, + store.maxBitrateKbpsFlow.first(), + "a null bitrate must clear the cap, not leave the previous one throttling playback", + ) + } + + @Test + fun `a value stored under a pre-cutover key name survives the rename`() = runTest { + // The upgrade case. Both keys were renamed by the settings cutover, and + // both read local-first — subtitle appearance drives downloaded + // playback with no server in the loop — so an orphaned slot is a + // silently reverted preference, not just a stale cache. + val appearance = SubtitleAppearance.DEFAULT + .copy(fontSize = SubtitleFontSizePreset.XXLarge) + .toJsonString() + val store = newStoreSeededWith { prefs -> + prefs[stringPreferencesKey("subtitle_appearance")] = appearance + prefs[intPreferencesKey("player.next_up_prompt_seconds")] = 12 + } + + assertEquals(SubtitleFontSizePreset.XXLarge, store.subtitleAppearanceFlow.first().fontSize) + assertEquals(12, store.nextUpPromptSecondsFlow.first()) + } + + @Test + fun `the rename migration never overwrites a value already under the new key`() = runTest { + // A canonical refresh or a fresh edit outranks whatever the pre-rename + // build left on disk; copying over it would revert the newer value. + val store = newStoreSeededWith { prefs -> + prefs[intPreferencesKey("player.next_up_prompt_seconds")] = 12 + prefs[intPreferencesKey(PlaybackSettingsKeys.NextUpPromptSeconds)] = 45 + } + + assertEquals(45, store.nextUpPromptSecondsFlow.first()) + } + + @Test + fun `writes are stamped with the server they were authored against`() = runTest { + // The flusher is application-scoped and its requests are relative, so a + // queued op that outlives a server switch can only be told apart by the + // origin the store stamps on it here. + val store = newStore() + store.setAutoSkipIntro(true) + + val call = fakeFlusher.calls.last { it.key == PlaybackSettingsKeys.AutoSkipIntro } + assertEquals(serverUrl, call.serverUrl) + } + + @Test + fun `a granular subtitle field projects into the composite on flush`() = runTest { + // A granular slot written WITHOUT a composite write — the state an + // upgrading user lands in, because ensureMigrated imports each legacy + // `subtitle.*` value straight into its granular slot. The contract has + // no key for those fields, so this is the only path that carries them + // to the server. + fakeLegacyCache.putString(serverUrl, PlaybackSettingsKeys.SubtitleFontSize, "xxlarge") + fakeLegacyCache.putString(serverUrl, PlaybackSettingsKeys.SubtitleTextColor, "#ff0000") + val store = newStore() + // Touch a flow so the migration import runs, then start clean. + store.subtitleAppearanceFlow.first() + fakeFlusher.calls.clear() + + store.flushProjectedSubtitleAppearance() + + val enqueued = fakeFlusher.calls.lastOrNull { it.key == PlaybackSettingsKeys.SubtitleAppearance } + assertTrue( + enqueued != null, + "a granular edit that never reaches the composite is device-local forever", + ) + val sent = SubtitleAppearance.decode(enqueued.value.orEmpty()) + assertEquals(SubtitleFontSizePreset.XXLarge, sent.fontSize) + assertEquals("#ff0000", sent.fontColor) + } + + @Test + fun `a granular subtitle field overlays the composite on read`() = runTest { + // The other half of the projection: until the flush catches up, the + // granular slot is the newer edit and reads must show it, or the + // settings screen renders the value the user just replaced. + fakeLegacyCache.putString(serverUrl, PlaybackSettingsKeys.SubtitleFontSize, "small") + val store = newStore() + + assertEquals(SubtitleFontSizePreset.Small, store.subtitleAppearanceFlow.first().fontSize) + } + + @Test + fun `flushing an unchanged projection enqueues nothing`() = runTest { + val store = newStore() + store.setSubtitleAppearance( + SubtitleAppearance.DEFAULT.copy(fontSize = SubtitleFontSizePreset.XXLarge), + ) + fakeFlusher.calls.clear() + + store.flushProjectedSubtitleAppearance() + + assertTrue( + fakeFlusher.calls.none { it.key == PlaybackSettingsKeys.SubtitleAppearance }, + "an unchanged projection must not enqueue a redundant write", + ) + } + @Test fun `flushPendingDeviceSettings delegates to flusher flushNow`() = runTest { val store = newStore() @@ -376,15 +676,21 @@ class AndroidPlayerSettingsStoreTest { } private class FakeServerSettingsFlusher : ServerSettingsFlusher { - data class Call(val profileId: String, val key: String, val value: String?, val isDelete: Boolean) + data class Call( + val profileId: String, + val key: String, + val value: String?, + val isDelete: Boolean, + val serverUrl: String, + ) val calls = mutableListOf() var flushNowCount: Int = 0 - override fun enqueue(profileId: String, key: String, value: String) { - calls.add(Call(profileId, key, value, isDelete = false)) + override fun enqueue(profileId: String, key: String, value: String, serverUrl: String) { + calls.add(Call(profileId, key, value, isDelete = false, serverUrl = serverUrl)) } - override fun enqueueDelete(profileId: String, key: String) { - calls.add(Call(profileId, key, value = null, isDelete = true)) + override fun enqueueDelete(profileId: String, key: String, serverUrl: String) { + calls.add(Call(profileId, key, value = null, isDelete = true, serverUrl = serverUrl)) } override suspend fun flushNow() { @@ -392,28 +698,41 @@ private class FakeServerSettingsFlusher : ServerSettingsFlusher { } } -/** Stub SettingsApi returning canned effective values; HttpClient never used. */ +/** One canned entry: a value stored at [scope] (default: profile_device). */ +private fun stored( + key: String, + value: JsonElement, + scope: String = SettingScope.PROFILE_DEVICE.wire, +): Pair = + key to EffectiveSettingValue(key = key, value = value, source = scope, scope = scope) + +/** One canned entry resolving to the contract default (nothing stored). */ +private fun defaulted(key: String, value: JsonElement): Pair = + key to EffectiveSettingValue( + key = key, + value = value, + source = EffectiveSettingValue.SOURCE_DEFAULT, + ) + +/** Stub SettingsApi returning canned canonical effective values; HttpClient never used. */ private class FakeSettingsApi( - effective: Map = emptyMap(), - hasDeviceOverride: Set = emptySet(), + effective: Map = emptyMap(), ) : SettingsApi(HttpClient()) { // Mutable so a single test can simulate the server's response // changing between two `refreshFromServer` calls without standing // up a second DataStore over the same file. - var effective: Map = effective - var hasDeviceOverride: Set = hasDeviceOverride - - override suspend fun getEffectiveSettings(keys: List): ApiResult { - val entries = keys.mapNotNull { key -> - val value = effective[key] ?: return@mapNotNull null - EffectiveSetting( - key = key, - effectiveValue = value, - source = "device", - hasDeviceOverride = key in hasDeviceOverride, - ) - } - return ApiResult.Success(EffectiveSettingsResponse(entries)) + var effective: Map = effective + var requestedKeys: List = emptyList() + + override suspend fun getEffectiveValues( + keys: List, + libraryIds: List, + seriesIds: List, + ): ApiResult { + requestedKeys = keys + // Like the server: answer only the keys this contract knows. + val entries = keys.mapNotNull { effective[it] } + return ApiResult.Success(EffectiveSettingValuesResponse(settings = entries, revision = 1)) } override suspend fun setDeviceSetting(key: String, value: String, profileId: String?) = diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/OverlayPrefsStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/OverlayPrefsStoreTest.kt new file mode 100644 index 000000000..c7e6c5743 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/OverlayPrefsStoreTest.kt @@ -0,0 +1,434 @@ +package org.prairieserver.prairie.common.settings + +import org.prairieserver.prairie.model.settings.EffectiveSettingValue +import org.prairieserver.prairie.model.settings.EffectiveSettingValuesResponse +import org.prairieserver.prairie.model.settings.SettingEntry +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.model.settings.SettingScope +import org.prairieserver.prairie.model.settings.SettingScopeIdentity +import org.prairieserver.prairie.model.settings.StoredSettingValue +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.api.OverlayConfigResponse +import org.prairieserver.prairie.network.api.SettingsApi +import org.prairieserver.prairie.overlays.CardOverlayPrefs +import org.prairieserver.prairie.overlays.OverlaySchema +import org.prairieserver.prairie.overlays.PresetId +import org.prairieserver.prairie.repository.SettingsRepository +import io.ktor.client.HttpClient +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class OverlayPrefsStoreTest { + + @Test + fun `hydrate reads typed canonical profile value without legacy endpoint`() = runTest { + val expected = prefs(PresetId.Vibrant) + val api = RecordingOverlaySettingsApi( + storedValue = Json.parseToJsonElement(OverlaySchema.serialize(expected)), + ) + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + + store.refresh() + + assertEquals(expected, store.prefs.value) + assertTrue(store.hasUserOverride) + assertEquals(listOf(SettingKeys.UI_CARD_OVERLAYS), api.effectiveRequests.single()) + assertEquals(0, api.legacyCalls) + } + + @Test + fun `clear lets the next session queue hydration behind an old refresh`() = runTest { + val firstUser = prefs(PresetId.Vibrant) + val secondUser = prefs(PresetId.Pill) + val api = RecordingOverlaySettingsApi( + storedValue = Json.parseToJsonElement(OverlaySchema.serialize(firstUser)), + ) + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + + val oldRead = api.pauseNextEffectiveRead() + val oldRefresh = launch { store.refresh() } + oldRead.started.await() + + store.clear() + api.storedValue = Json.parseToJsonElement(OverlaySchema.serialize(secondUser)) + val nextHydration = launch { store.hydrateIfNeeded() } + runCurrent() + assertEquals(1, api.effectiveRequests.size) + + oldRead.release.complete(Unit) + oldRefresh.join() + nextHydration.join() + + assertEquals(2, api.effectiveRequests.size) + assertEquals(secondUser, store.prefs.value) + assertTrue(store.hasUserOverride) + } + + @Test + fun `save and reset use canonical profile scope with typed object`() = runTest { + val initial = prefs(PresetId.Vibrant) + val adminDefault = prefs(PresetId.Minimal) + val api = RecordingOverlaySettingsApi( + storedValue = Json.parseToJsonElement(OverlaySchema.serialize(initial)), + adminDefaults = OverlaySchema.serialize(adminDefault), + ) + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + store.refresh() + + val edited = prefs(PresetId.Pill) + store.setPrefs(edited) + advanceUntilIdle() + + val put = api.puts.single() + assertEquals(SettingKeys.UI_CARD_OVERLAYS, put.key) + assertEquals(SettingScope.PROFILE, put.scope.scope) + assertIs(put.value) + assertEquals(edited, store.prefs.value) + assertTrue(store.hasUserOverride) + + store.resetToDefaults() + + assertEquals(1, api.deleteCount) + assertEquals(adminDefault, store.prefs.value) + assertFalse(store.hasUserOverride) + assertEquals(0, api.legacyCalls) + } + + @Test + fun `failed canonical save restores last confirmed value`() = runTest { + val confirmed = prefs(PresetId.Vibrant) + val api = RecordingOverlaySettingsApi( + storedValue = Json.parseToJsonElement(OverlaySchema.serialize(confirmed)), + ) + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + store.refresh() + api.putFailure = ApiResult.Error(400, "invalid_value", "Rejected overlay settings") + + store.setPrefs(prefs(PresetId.Square)) + advanceUntilIdle() + + assertEquals(confirmed, store.prefs.value) + assertTrue(store.hasUserOverride) + assertEquals("Rejected overlay settings", store.lastError.value) + assertEquals(0, api.legacyCalls) + } + + @Test + fun `stale refresh cannot replace a newer confirmed save`() = runTest { + val original = prefs(PresetId.Vibrant) + val saved = prefs(PresetId.Pill) + val api = RecordingOverlaySettingsApi( + storedValue = Json.parseToJsonElement(OverlaySchema.serialize(original)), + ) + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + store.refresh() + + api.overlayEnabled = false + val staleRead = api.pauseNextEffectiveRead() + val refreshJob = launch { store.refresh() } + staleRead.started.await() + + store.setPrefs(saved) + runCurrent() + assertEquals(saved, OverlaySchema.parse(api.storedValue.toString())) + + staleRead.release.complete(Unit) + refreshJob.join() + assertEquals(saved, store.prefs.value) + assertFalse(store.enabled.value) + + api.putFailure = ApiResult.Error(400, "invalid_value", "Rejected overlay settings") + store.setPrefs(prefs(PresetId.Square)) + advanceUntilIdle() + + assertEquals(saved, store.prefs.value) + assertTrue(store.hasUserOverride) + } + + @Test + fun `refresh started during rejected save cannot erase its error`() = runTest { + val confirmed = prefs(PresetId.Vibrant) + val rejected = prefs(PresetId.Square) + val api = RecordingOverlaySettingsApi( + storedValue = Json.parseToJsonElement(OverlaySchema.serialize(confirmed)), + ) + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + store.refresh() + + api.putFailure = ApiResult.Error(400, "invalid_value", "Rejected overlay settings") + val pausedPut = api.pauseNextPut() + store.setPrefs(rejected) + pausedPut.started.await() + + val staleRead = api.pauseNextEffectiveRead() + val refreshJob = launch { store.refresh() } + staleRead.started.await() + + pausedPut.release.complete(Unit) + runCurrent() + assertEquals("Rejected overlay settings", store.lastError.value) + + staleRead.release.complete(Unit) + refreshJob.join() + assertEquals(confirmed, store.prefs.value) + assertEquals("Rejected overlay settings", store.lastError.value) + } + + @Test + fun `stale user read still applies new admin fallback after rejected save`() = runTest { + val oldDefault = prefs(PresetId.Minimal) + val newDefault = prefs(PresetId.Vibrant) + val api = RecordingOverlaySettingsApi( + adminDefaults = OverlaySchema.serialize(oldDefault), + ) + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + store.refresh() + assertEquals(oldDefault, store.prefs.value) + assertFalse(store.hasUserOverride) + + api.adminDefaults = OverlaySchema.serialize(newDefault) + api.putFailure = ApiResult.Error(400, "invalid_value", "Rejected overlay settings") + val pausedPut = api.pauseNextPut() + store.setPrefs(prefs(PresetId.Square)) + pausedPut.started.await() + + val staleRead = api.pauseNextEffectiveRead() + val refreshJob = launch { store.refresh() } + staleRead.started.await() + + pausedPut.release.complete(Unit) + runCurrent() + assertEquals(oldDefault, store.prefs.value) + assertEquals("Rejected overlay settings", store.lastError.value) + + staleRead.release.complete(Unit) + refreshJob.join() + assertEquals(newDefault, store.prefs.value) + assertEquals("Rejected overlay settings", store.lastError.value) + + store.setPrefs(prefs(PresetId.Pill)) + advanceUntilIdle() + assertEquals(newDefault, store.prefs.value) + } + + @Test + fun `edit during reset is persisted after delete and remains visible`() = runTest { + val original = prefs(PresetId.Vibrant) + val saved = prefs(PresetId.Pill) + val api = RecordingOverlaySettingsApi( + storedValue = Json.parseToJsonElement(OverlaySchema.serialize(original)), + ) + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + store.refresh() + + api.overlayEnabled = false + val staleRead = api.pauseNextEffectiveRead() + val refreshJob = launch { store.refresh() } + staleRead.started.await() + + val pausedDelete = api.pauseNextDelete() + val resetJob = launch { store.resetToDefaults() } + pausedDelete.started.await() + + store.setPrefs(saved) + runCurrent() + assertEquals(saved, store.prefs.value) + assertTrue(api.puts.isEmpty()) + + staleRead.release.complete(Unit) + refreshJob.join() + assertFalse(store.enabled.value) + assertEquals(saved, store.prefs.value) + + pausedDelete.release.complete(Unit) + resetJob.join() + advanceUntilIdle() + + assertEquals(listOf("DELETE", "PUT"), api.mutationEvents) + assertEquals(saved, OverlaySchema.parse(api.storedValue.toString())) + assertEquals(saved, store.prefs.value) + assertTrue(store.hasUserOverride) + } + + @Test + fun `refresh started during rejected reset cannot erase its error`() = runTest { + val confirmed = prefs(PresetId.Vibrant) + val api = RecordingOverlaySettingsApi( + storedValue = Json.parseToJsonElement(OverlaySchema.serialize(confirmed)), + ) + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + store.refresh() + + api.deleteFailure = ApiResult.Error(500, "delete_failed", "Reset failed") + val pausedDelete = api.pauseNextDelete() + val resetJob = launch { store.resetToDefaults() } + pausedDelete.started.await() + + val staleRead = api.pauseNextEffectiveRead() + val refreshJob = launch { store.refresh() } + staleRead.started.await() + + pausedDelete.release.complete(Unit) + resetJob.join() + assertEquals("Reset failed", store.lastError.value) + + staleRead.release.complete(Unit) + refreshJob.join() + assertEquals(confirmed, store.prefs.value) + assertEquals("Reset failed", store.lastError.value) + } + + private fun prefs(preset: PresetId): CardOverlayPrefs = + OverlaySchema.buildDefaults().copy(preset = preset) +} + +private class RecordingOverlaySettingsApi( + var storedValue: JsonElement? = null, + var adminDefaults: String? = null, +) : SettingsApi(HttpClient()) { + + data class CallGate( + val started: CompletableDeferred = CompletableDeferred(), + val release: CompletableDeferred = CompletableDeferred(), + ) + + data class Put( + val key: String, + val scope: SettingScopeIdentity, + val value: JsonElement, + ) + + val effectiveRequests = mutableListOf>() + val puts = mutableListOf() + val mutationEvents = mutableListOf() + var deleteCount = 0 + var legacyCalls = 0 + var overlayEnabled = true + var putFailure: ApiResult? = null + var deleteFailure: ApiResult? = null + private var nextEffectiveReadGate: CallGate? = null + private var nextPutGate: CallGate? = null + private var nextDeleteGate: CallGate? = null + + fun pauseNextEffectiveRead(): CallGate = CallGate().also { + check(nextEffectiveReadGate == null) + nextEffectiveReadGate = it + } + + fun pauseNextPut(): CallGate = CallGate().also { + check(nextPutGate == null) + nextPutGate = it + } + + fun pauseNextDelete(): CallGate = CallGate().also { + check(nextDeleteGate == null) + nextDeleteGate = it + } + + override suspend fun overlayConfig(): ApiResult = + ApiResult.Success(OverlayConfigResponse(enabled = overlayEnabled, defaults = adminDefaults)) + + override suspend fun getEffectiveValues( + keys: List, + libraryIds: List, + seriesIds: List, + ): ApiResult { + effectiveRequests += keys + val value = storedValue + nextEffectiveReadGate?.also { gate -> + nextEffectiveReadGate = null + gate.started.complete(Unit) + gate.release.await() + } + return ApiResult.Success( + EffectiveSettingValuesResponse( + settings = listOf( + EffectiveSettingValue( + key = SettingKeys.UI_CARD_OVERLAYS, + value = value ?: JsonNull, + source = if (value == null) { + EffectiveSettingValue.SOURCE_DEFAULT + } else { + SettingScope.PROFILE.wire + }, + scope = SettingScope.PROFILE.wire.takeIf { value != null }, + ), + ), + revision = SettingKeys.REVISION, + ), + ) + } + + override suspend fun putValue( + key: String, + scope: SettingScopeIdentity, + value: JsonElement, + mutationId: String, + profileId: String?, + ): ApiResult { + puts += Put(key, scope, value) + nextPutGate?.also { gate -> + nextPutGate = null + gate.started.complete(Unit) + gate.release.await() + } + putFailure?.let { return it } + storedValue = value + mutationEvents += "PUT" + return ApiResult.Success( + StoredSettingValue( + key = key, + scope = scope.scope.wire, + value = value, + ), + ) + } + + override suspend fun deleteValue( + key: String, + scope: SettingScopeIdentity, + profileId: String?, + ): ApiResult { + deleteCount += 1 + assertEquals(SettingKeys.UI_CARD_OVERLAYS, key) + assertEquals(SettingScope.PROFILE, scope.scope) + nextDeleteGate?.also { gate -> + nextDeleteGate = null + gate.started.complete(Unit) + gate.release.await() + } + deleteFailure?.let { return it } + storedValue = null + mutationEvents += "DELETE" + return ApiResult.Success(Unit) + } + + override suspend fun getSetting(key: String): ApiResult { + legacyCalls += 1 + error("legacy getSetting must not be called") + } + + override suspend fun setSetting(key: String, value: String): ApiResult { + legacyCalls += 1 + error("legacy setSetting must not be called") + } + + override suspend fun deleteSetting(key: String): ApiResult { + legacyCalls += 1 + error("legacy deleteSetting must not be called") + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerDrivenConfigRefresherTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerDrivenConfigRefresherTest.kt index 63fcb8711..f216fc344 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerDrivenConfigRefresherTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerDrivenConfigRefresherTest.kt @@ -164,12 +164,12 @@ private class FakePlayerSettingsStore : PlayerSettingsStore { override val playbackSpeedFlow: Flow = flowOf(1.0) override val audioSyncMsFlow: Flow = flowOf(0) override val subtitleSyncMsFlow: Flow = flowOf(0) - override fun subtitleSyncMsFor(contentId: String?): Flow = subtitleSyncMsFlow override val nextUpPromptSecondsFlow: Flow = flowOf(30) override val sleepTimerDefaultMinutesFlow: Flow = flowOf(30) override val resumeRewindSecondsFlow: Flow = flowOf(7) override val passOutThresholdFlow: Flow = flowOf(3) override val preferredQualityFlow: Flow = flowOf("auto") + override val maxBitrateKbpsFlow: Flow = flowOf(null) override val audioLanguageFlow: Flow = flowOf("") override val videoGravityFlow: Flow = flowOf("fit") override val orientationModeFlow: Flow = flowOf("auto") @@ -193,16 +193,17 @@ private class FakePlayerSettingsStore : PlayerSettingsStore { override suspend fun setPlaybackSpeed(value: Double) = Unit override suspend fun setAudioSyncMs(value: Int) = Unit override suspend fun setSubtitleSyncMs(value: Int) = Unit - override suspend fun setSubtitleSyncMsFor(contentId: String, value: Int) = Unit override suspend fun setNextUpPromptSeconds(value: Int) = Unit override suspend fun setSleepTimerDefaultMinutes(value: Int) = Unit override suspend fun setResumeRewindSeconds(value: Int) = Unit override suspend fun setPassOutThreshold(value: Int) = Unit override suspend fun setPreferredQuality(value: String) = Unit + override suspend fun setQuality(resolution: String, bitrateKbps: Int?) = Unit override suspend fun setAudioLanguage(value: String) = Unit override suspend fun setVideoGravity(value: String) = Unit override suspend fun setOrientationMode(value: String) = Unit override suspend fun setSubtitleAppearance(value: SubtitleAppearance) = Unit + override suspend fun flushProjectedSubtitleAppearance() = Unit override suspend fun refreshFromServer() { refreshCalls++ } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusherTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusherTest.kt index ed805671a..7f60454d8 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusherTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusherTest.kt @@ -1,5 +1,11 @@ package org.prairieserver.prairie.common.settings +import org.prairieserver.prairie.model.settings.PlaybackSettingsKeys +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.model.settings.SettingScope +import org.prairieserver.prairie.model.settings.SettingScopeIdentity +import org.prairieserver.prairie.model.settings.StoredSettingValue +import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.api.SettingsApi import io.ktor.client.HttpClient @@ -7,23 +13,42 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotEquals import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class ServerSettingsFlusherTest { + // Real contract keys so the flusher's remote-key gate and type tables + // classify them the way production traffic is classified. + private val boolKey = SettingKeys.PLAYBACK_AUTO_SKIP_INTRO + private val intKey = SettingKeys.PLAYER_AUDIO_SYNC_MS + private val doubleKey = SettingKeys.PLAYER_PLAYBACK_SPEED + private val stringKey = SettingKeys.PLAYBACK_PREFERRED_QUALITY + private val languageKey = SettingKeys.PLAYBACK_AUDIO_LANGUAGE + private val objectKey = SettingKeys.PLAYBACK_SUBTITLE_APPEARANCE + + // The server ops are authored against. The flusher's requests are relative + // and it outlives a server switch, so every op carries its origin. + private val serverUrl = "https://one.example" + private val otherServerUrl = "https://two.example" + @Test fun `enqueue debounces multiple writes for same key`() = runTest { val api = RecordingSettingsApi() val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) - flusher.enqueue("p1", "key.a", "v1") + flusher.enqueue("p1", stringKey, "480p", serverUrl) advanceTimeBy(50) - flusher.enqueue("p1", "key.a", "v2") + flusher.enqueue("p1", stringKey, "720p", serverUrl) advanceTimeBy(50) - flusher.enqueue("p1", "key.a", "v3") + flusher.enqueue("p1", stringKey, "1080p", serverUrl) // Not yet — total elapsed 100, debounce 200. assertEquals(0, api.calls.size) @@ -31,8 +56,8 @@ class ServerSettingsFlusherTest { advanceUntilIdle() assertEquals(1, api.calls.size, "expected only the latest write to be sent (coalesced)") - assertEquals("key.a", api.calls.first().key) - assertEquals("v3", api.calls.first().value) + assertEquals(stringKey, api.calls.first().key) + assertEquals(JsonPrimitive("1080p"), api.calls.first().value) } @Test @@ -40,32 +65,67 @@ class ServerSettingsFlusherTest { val api = RecordingSettingsApi() val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) - flusher.enqueue("p1", "key.a", "1") - flusher.enqueue("p1", "key.b", "2") - flusher.enqueue("p1", "key.c", "3") + flusher.enqueue("p1", boolKey, "true", serverUrl) + flusher.enqueue("p1", intKey, "120", serverUrl) + flusher.enqueue("p1", stringKey, "720p", serverUrl) advanceUntilIdle() assertEquals(3, api.calls.size) val byKey = api.calls.associate { it.key to it.value } - assertEquals("1", byKey["key.a"]) - assertEquals("2", byKey["key.b"]) - assertEquals("3", byKey["key.c"]) + assertEquals(JsonPrimitive(true), byKey[boolKey]) + assertEquals(JsonPrimitive(120L), byKey[intKey]) + assertEquals(JsonPrimitive("720p"), byKey[stringKey]) } @Test - fun `enqueue then enqueue with new value coalesces to latest`() = runTest { + fun `values are encoded as the contract JSON type`() = runTest { val api = RecordingSettingsApi() val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) - flusher.enqueue("p1", "key.x", "first") - flusher.enqueue("p1", "key.x", "second") - flusher.enqueue("p1", "key.x", "final") + flusher.enqueue("p1", boolKey, "false", serverUrl) + flusher.enqueue("p1", intKey, "-250", serverUrl) + flusher.enqueue("p1", doubleKey, "1.5", serverUrl) + flusher.enqueue("p1", languageKey, "en-US", serverUrl) + flusher.enqueue("p1", objectKey, SubtitleAppearance.DEFAULT.toJsonString(), serverUrl) + + advanceUntilIdle() + + val byKey = api.calls.associate { it.key to it.value } + assertEquals(JsonPrimitive(false), byKey[boolKey]) + assertEquals(JsonPrimitive(-250L), byKey[intKey]) + assertEquals(JsonPrimitive(1.5), byKey[doubleKey]) + assertEquals(JsonPrimitive("en-US"), byKey[languageKey]) + assertTrue(byKey[objectKey] is JsonObject, "subtitle appearance must go up as a JSON object") + } + + @Test + fun `empty language tag is sent as JSON null`() = runTest { + // The store spells "no preference" as ""; the contract spells it as + // null (its language_tag validator rejects the empty string). + val api = RecordingSettingsApi() + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + flusher.enqueue("p1", languageKey, "", serverUrl) advanceUntilIdle() assertEquals(1, api.calls.size) - assertEquals("final", api.calls.first().value) + assertEquals(JsonNull, api.calls.first().value) + } + + @Test + fun `writes address the profile_device scope with the enqueued profile`() = runTest { + val api = RecordingSettingsApi() + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", boolKey, "true", serverUrl) + flusher.enqueueDelete("p2", intKey, serverUrl) + advanceUntilIdle() + + assertEquals(2, api.calls.size) + assertTrue(api.calls.all { it.scope == SettingScopeIdentity.profileDevice() }) + assertEquals("p1", api.calls.first { it.key == boolKey }.profileId) + assertEquals("p2", api.calls.first { it.key == intKey }.profileId) } @Test @@ -73,15 +133,15 @@ class ServerSettingsFlusherTest { val api = RecordingSettingsApi() val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) - flusher.enqueue("p1", "key.shared", "one") - flusher.enqueue("p2", "key.shared", "two") + flusher.enqueue("p1", stringKey, "720p", serverUrl) + flusher.enqueue("p2", stringKey, "1080p", serverUrl) advanceUntilIdle() assertEquals(2, api.calls.size) val byProfile = api.calls.associate { it.profileId to it.value } - assertEquals("one", byProfile["p1"]) - assertEquals("two", byProfile["p2"]) + assertEquals(JsonPrimitive("720p"), byProfile["p1"]) + assertEquals(JsonPrimitive("1080p"), byProfile["p2"]) } @Test @@ -89,14 +149,14 @@ class ServerSettingsFlusherTest { val api = RecordingSettingsApi() val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) - flusher.enqueueDelete("p1", "key.x") - flusher.enqueue("p1", "key.x", "after") + flusher.enqueueDelete("p1", stringKey, serverUrl) + flusher.enqueue("p1", stringKey, "480p", serverUrl) advanceUntilIdle() assertEquals(1, api.calls.size, "set should win after delete since it was enqueued later") - assertEquals(RecordingSettingsApi.Call.Kind.SET, api.calls.first().kind) - assertEquals("after", api.calls.first().value) + assertEquals(RecordingSettingsApi.Call.Kind.PUT, api.calls.first().kind) + assertEquals(JsonPrimitive("480p"), api.calls.first().value) } @Test @@ -104,14 +164,14 @@ class ServerSettingsFlusherTest { val api = RecordingSettingsApi() val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) - flusher.enqueue("p1", "key.x", "before") - flusher.enqueueDelete("p1", "key.x") + flusher.enqueue("p1", stringKey, "480p", serverUrl) + flusher.enqueueDelete("p1", stringKey, serverUrl) advanceUntilIdle() assertEquals(1, api.calls.size, "delete should win after set since it was enqueued later") assertEquals(RecordingSettingsApi.Call.Kind.DELETE, api.calls.first().kind) - assertEquals("key.x", api.calls.first().key) + assertEquals(stringKey, api.calls.first().key) } @Test @@ -119,17 +179,17 @@ class ServerSettingsFlusherTest { val api = RecordingSettingsApi() val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 5_000) - flusher.enqueue("p1", "key.a", "v1") - flusher.enqueueDelete("p1", "key.b") + flusher.enqueue("p1", boolKey, "true", serverUrl) + flusher.enqueueDelete("p1", intKey, serverUrl) // Don't advance — flushNow should drain immediately. flusher.flushNow() assertEquals(2, api.calls.size) val byKey = api.calls.associateBy { it.key } - assertEquals(RecordingSettingsApi.Call.Kind.SET, byKey["key.a"]?.kind) - assertEquals("v1", byKey["key.a"]?.value) - assertEquals(RecordingSettingsApi.Call.Kind.DELETE, byKey["key.b"]?.kind) + assertEquals(RecordingSettingsApi.Call.Kind.PUT, byKey[boolKey]?.kind) + assertEquals(JsonPrimitive(true), byKey[boolKey]?.value) + assertEquals(RecordingSettingsApi.Call.Kind.DELETE, byKey[intKey]?.kind) } @Test @@ -143,56 +203,360 @@ class ServerSettingsFlusherTest { } @Test - fun `errors from setDeviceSetting are swallowed and do not abort future flushes`() = runTest { - val api = RecordingSettingsApi(failNext = true) + fun `transient failure keeps the write queued and retries with the same mutation id`() = runTest { + val api = RecordingSettingsApi() + api.failNextPuts(1, ApiResult.Error(503, "unavailable", "restarting")) + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", boolKey, "true", serverUrl) + advanceUntilIdle() + + assertEquals(2, api.calls.size, "failed write must be retried, not dropped") + assertEquals(api.calls[0].mutationId, api.calls[1].mutationId, + "a retry must replay the SAME mutation id so the server can dedupe it") + assertEquals(JsonPrimitive(true), api.calls[1].value) + } + + @Test + fun `network failure keeps the write queued and retries with the same mutation id`() = runTest { + val api = RecordingSettingsApi() + api.failNextPuts(2, ApiResult.NetworkError(RuntimeException("offline"))) + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", intKey, "500", serverUrl) + advanceUntilIdle() + + assertEquals(3, api.calls.size) + assertTrue(api.calls.all { it.mutationId == api.calls.first().mutationId }) + } + + @Test + fun `write survives exhausting automatic retries and flushes on the next trigger`() = runTest { + val api = RecordingSettingsApi() + api.failNextPuts(Int.MAX_VALUE, ApiResult.Error(500, "internal", "boom")) + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", boolKey, "true", serverUrl) + advanceUntilIdle() + + // Initial attempt + capped automatic retries, then parked — the old + // behavior dropped the write on the first failure. + val attemptsWhileParked = api.calls.size + assertTrue(attemptsWhileParked >= 2, "expected automatic retries, got $attemptsWhileParked") + + // The op is still queued: a later explicit flush (app foreground, + // player exit) replays it — same id — and this time it lands. + api.failNextPuts(0, ApiResult.Error(500, "internal", "boom")) + flusher.flushNow() + + assertEquals(attemptsWhileParked + 1, api.calls.size) + assertTrue(api.calls.all { it.mutationId == api.calls.first().mutationId }) + } + + @Test + fun `contract rejection drops the write instead of retrying forever`() = runTest { + val api = RecordingSettingsApi() + api.failNextPuts(Int.MAX_VALUE, ApiResult.Error(400, "invalid_value", "expected a boolean")) val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) - flusher.enqueue("p1", "key.fail", "boom") + flusher.enqueue("p1", boolKey, "true", serverUrl) advanceUntilIdle() - // Even though that one errored internally, a subsequent enqueue should still flush. - flusher.enqueue("p1", "key.ok", "yay") + assertEquals(1, api.calls.size, "a 4xx contract rejection retries identically forever; drop it") + + // And the queue is actually empty afterwards. + flusher.flushNow() + assertEquals(1, api.calls.size) + } + + @Test + fun `mutation id conflict drops the write`() = runTest { + val api = RecordingSettingsApi() + api.failNextPuts(Int.MAX_VALUE, ApiResult.Error(409, "mutation_id_conflict", "id reused")) + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", boolKey, "true", serverUrl) advanceUntilIdle() - assertTrue(api.calls.any { it.key == "key.ok" && it.value == "yay" }) + assertEquals(1, api.calls.size) + } + + @Test + fun `delete answered not_found is treated as already done`() = runTest { + val api = RecordingSettingsApi() + api.failNextDeletes(Int.MAX_VALUE, ApiResult.Error(404, "not_found", "No value is set at this scope")) + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueueDelete("p1", stringKey, serverUrl) + advanceUntilIdle() + + assertEquals(1, api.calls.size, "nothing stored means the reset is already true; no retry") + } + + @Test + fun `transient delete failure keeps the delete queued`() = runTest { + val api = RecordingSettingsApi() + api.failNextDeletes(1, ApiResult.Error(502, "bad_gateway", "proxy hiccup")) + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueueDelete("p1", stringKey, serverUrl) + advanceUntilIdle() + + assertEquals(2, api.calls.size) + assertTrue(api.calls.all { it.kind == RecordingSettingsApi.Call.Kind.DELETE }) + } + + @Test + fun `re-enqueueing a different value mints a fresh mutation id`() = runTest { + val api = RecordingSettingsApi() + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", stringKey, "720p", serverUrl) + advanceUntilIdle() + flusher.enqueue("p1", stringKey, "1080p", serverUrl) + advanceUntilIdle() + + assertEquals(2, api.calls.size) + assertNotEquals(api.calls[0].mutationId, api.calls[1].mutationId, + "different content must never reuse a mutation id (409 conflict by design)") + } + + @Test + fun `a retained retry is dropped once its origin server is no longer active`() = runTest { + // The failure mode: a write fails transiently against server one, stays + // queued, and the user switches to server two. Requests are relative and + // this flusher is application-scoped, so replaying the op now would + // write server one's device setting to server two — which a restored or + // cloned server recognizing the same profile id would accept. + val api = RecordingSettingsApi() + // Fail every attempt, so the op exhausts its automatic retries and is + // still sitting in the queue when the switch happens. + api.failNextPuts(Int.MAX_VALUE, ApiResult.Error(500, "internal", "boom")) + var activeServer = serverUrl + val flusher = DefaultServerSettingsFlusher( + api, this, debounceMs = 200, getServerUrl = { activeServer }, + ) + + flusher.enqueue("p1", stringKey, "720p", serverUrl) + advanceUntilIdle() + val attemptsBeforeSwitch = api.calls.size + assertTrue(attemptsBeforeSwitch >= 1, "the write must have been attempted at least once") + + activeServer = otherServerUrl + flusher.flushNow() + advanceUntilIdle() + + assertEquals( + attemptsBeforeSwitch, + api.calls.size, + "a queued write must never be replayed against a server it was not authored for", + ) + + // And it is gone, not merely deferred: a later flush against the + // original server must not resurrect it either. + activeServer = serverUrl + flusher.flushNow() + advanceUntilIdle() + assertEquals( + attemptsBeforeSwitch, + api.calls.size, + "the dropped op must not be revived by a later flush", + ) + } + + @Test + fun `a queued write still lands when the active server is unchanged`() = runTest { + // The other side of the guard: same setup, no switch. The retry has to + // go through, or the drop rule would quietly break normal persistence. + val api = RecordingSettingsApi() + api.failNextPuts(1, ApiResult.Error(500, "internal", "boom")) + val flusher = DefaultServerSettingsFlusher( + api, this, debounceMs = 200, getServerUrl = { serverUrl }, + ) + + flusher.enqueue("p1", stringKey, "720p", serverUrl) + advanceUntilIdle() + + assertTrue( + api.calls.count { it.key == stringKey } >= 2, + "a transient failure on the still-active server must retry", + ) + } + + @Test + fun `failure does not abort other queued writes or future flushes`() = runTest { + val api = RecordingSettingsApi() + api.failNextPuts(1, ApiResult.Error(500, "internal", "boom")) + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", boolKey, "true", serverUrl) + advanceUntilIdle() + + flusher.enqueue("p1", stringKey, "720p", serverUrl) + advanceUntilIdle() + + assertTrue(api.calls.any { it.key == stringKey && it.value == JsonPrimitive("720p") }) + // And the originally failed write also landed in the end. + assertTrue(api.calls.count { it.key == boolKey } >= 2) + } + + @Test + fun `a newer value flushed in the same drain is not reverted by the failed older one`() = runTest { + // The shape that loses a user edit: flushNow() drains on the caller's + // coroutine (nothing cancels it), so a value the user changes while + // the first PUT is in flight is drained and sent by a LATER pass of + // the same drain. If the failed older op stayed queued, the retry + // would replay it over the newer value the server already accepted. + val api = RecordingSettingsApi() + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + api.failNextPuts(1, ApiResult.Error(502, "bad_gateway", "proxy hiccup")) + api.onPut = { call -> + // The user edits the same setting while the first PUT is in flight. + if (call.value == JsonPrimitive("480p")) flusher.enqueue("p1", stringKey, "1080p", serverUrl) + } + + flusher.enqueue("p1", stringKey, "480p", serverUrl) + flusher.flushNow() + advanceUntilIdle() + + assertEquals( + listOf(JsonPrimitive("480p"), JsonPrimitive("1080p")), + api.calls.map { it.value }, + "the superseded value must not be replayed after the newer one landed", + ) + } + + @Test + fun `a delete that failed is not replayed after a newer set landed`() = runTest { + // Same defect, worse outcome: a re-queued delete clears a value the + // user explicitly chose after the reset. + val api = RecordingSettingsApi() + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + api.failNextDeletes(1, ApiResult.Error(503, "unavailable", "restarting")) + api.onDelete = { flusher.enqueue("p1", stringKey, "1080p", serverUrl) } + + flusher.enqueueDelete("p1", stringKey, serverUrl) + flusher.flushNow() + advanceUntilIdle() + + assertEquals( + listOf(RecordingSettingsApi.Call.Kind.DELETE, RecordingSettingsApi.Call.Kind.PUT), + api.calls.map { it.kind }, + "the failed delete must not be replayed over the value set after it", + ) + assertEquals(JsonPrimitive("1080p"), api.calls.last().value) + } + + @Test + fun `a newer value that also fails is the one retried`() = runTest { + // Evicting the stale entry must not lose a genuine failure: when the + // newer op fails too, it is the newer op — and its id — that stays + // queued. + val api = RecordingSettingsApi() + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + api.failNextPuts(2, ApiResult.Error(500, "internal", "boom")) + api.onPut = { call -> + if (call.value == JsonPrimitive("480p")) flusher.enqueue("p1", stringKey, "1080p", serverUrl) + } + + flusher.enqueue("p1", stringKey, "480p", serverUrl) + flusher.flushNow() + advanceUntilIdle() + + assertEquals(3, api.calls.size, "the newer failed write must still be retried") + assertEquals(JsonPrimitive("1080p"), api.calls[2].value) + assertEquals( + api.calls[1].mutationId, api.calls[2].mutationId, + "the retry replays the newer write's own id", + ) + } + + @Test + fun `keys the contract does not store never reach the server`() = runTest { + val api = RecordingSettingsApi() + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", PlaybackSettingsKeys.SubtitleFontSize, "large", serverUrl) + flusher.enqueue("p1", "made.up_key", "x", serverUrl) + advanceUntilIdle() + flusher.flushNow() + + assertEquals(0, api.calls.size, "non-remote keys would 404 as unknown_setting; drop locally") } } /** - * Records every setDeviceSetting / deleteDeviceSetting call. Constructed - * with a no-op HttpClient because we override the only methods the - * flusher invokes — the underlying client is never touched. + * Records every putValue / deleteValue call. Constructed with a no-op + * HttpClient because we override the only methods the flusher invokes — + * the underlying client is never touched. */ -private class RecordingSettingsApi( - private val failNext: Boolean = false, -) : SettingsApi(HttpClient()) { +private class RecordingSettingsApi : SettingsApi(HttpClient()) { data class Call( val kind: Kind, val key: String, - val value: String?, + val value: JsonElement?, val profileId: String?, + val mutationId: String?, + val scope: SettingScopeIdentity, ) { - enum class Kind { SET, DELETE } + enum class Kind { PUT, DELETE } } val calls = mutableListOf() - private var failedOnce = false - override suspend fun setDeviceSetting( + /** + * Runs while a call is "in flight", before its result is returned — the + * hook for simulating the user editing the same setting during a flush. + */ + var onPut: ((Call) -> Unit)? = null + var onDelete: ((Call) -> Unit)? = null + + private var putFailuresRemaining = 0 + private var putFailure: ApiResult? = null + private var deleteFailuresRemaining = 0 + private var deleteFailure: ApiResult? = null + + fun failNextPuts(count: Int, failure: ApiResult) { + putFailuresRemaining = count + putFailure = failure + } + + fun failNextDeletes(count: Int, failure: ApiResult) { + deleteFailuresRemaining = count + deleteFailure = failure + } + + override suspend fun putValue( key: String, - value: String, + scope: SettingScopeIdentity, + value: JsonElement, + mutationId: String, profileId: String?, - ): ApiResult { - calls.add(Call(Call.Kind.SET, key, value, profileId)) - if (failNext && !failedOnce) { - failedOnce = true - return ApiResult.Error(500, "internal", "boom") + ): ApiResult { + val call = Call(Call.Kind.PUT, key, value, profileId, mutationId, scope) + calls.add(call) + onPut?.invoke(call) + if (putFailuresRemaining > 0) { + putFailuresRemaining-- + return putFailure ?: ApiResult.Error(500, "internal", "boom") } - return ApiResult.Success(Unit) + return ApiResult.Success( + StoredSettingValue(key = key, scope = SettingScope.PROFILE_DEVICE.wire, value = value), + ) } - override suspend fun deleteDeviceSetting(key: String): ApiResult { - calls.add(Call(Call.Kind.DELETE, key, null, null)) + override suspend fun deleteValue( + key: String, + scope: SettingScopeIdentity, + profileId: String?, + ): ApiResult { + val call = Call(Call.Kind.DELETE, key, null, profileId, null, scope) + calls.add(call) + onDelete?.invoke(call) + if (deleteFailuresRemaining > 0) { + deleteFailuresRemaining-- + return deleteFailure ?: ApiResult.Error(500, "internal", "boom") + } return ApiResult.Success(Unit) } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/SubtitleSyncOverridesTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/SubtitleSyncOverridesTest.kt deleted file mode 100644 index b863d5aa5..000000000 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/SubtitleSyncOverridesTest.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.prairieserver.prairie.common.settings - -import org.junit.Assert.assertEquals -import org.junit.Test - -class SubtitleSyncOverridesTest { - @Test - fun roundTripsEntries() { - val encoded = encodeSubtitleSyncOverrides(mapOf("movie-1" to -250, "episode-2" to 1_500)) - assertEquals(mapOf("movie-1" to -250, "episode-2" to 1_500), decodeSubtitleSyncOverrides(encoded)) - } - - @Test - fun malformedLinesAreDroppedNotGuessedAt() { - val decoded = decodeSubtitleSyncOverrides("good=100\nbroken\nbad=notanumber\n=500\n") - assertEquals(mapOf("good" to 100), decoded) - } - - @Test - fun idsCarryingSeparatorsAreRefused() { - val encoded = encodeSubtitleSyncOverrides( - mapOf("ok" to 1, "bad=id" to 2, "bad\nid" to 3, "" to 4), - ) - assertEquals(mapOf("ok" to 1), decodeSubtitleSyncOverrides(encoded)) - } - - @Test - fun theMapIsBoundedKeepingTheMostRecent() { - val decoded = decodeSubtitleSyncOverrides( - encodeSubtitleSyncOverrides((1..250).associate { "item-$it" to it }), - ) - assertEquals(200, decoded.size) - assertEquals(250, decoded["item-250"]) - assertEquals(null, decoded["item-1"]) - } -} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/DirectorCreditTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/DirectorCreditTest.kt new file mode 100644 index 000000000..bfabee051 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/DirectorCreditTest.kt @@ -0,0 +1,65 @@ +package org.prairieserver.prairie.common.ui + +import org.prairieserver.prairie.model.catalog.CrewMember +import org.prairieserver.prairie.model.catalog.ItemDetail +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DirectorCreditTest { + @Test + fun movieCreditMatchesExactDirectorJobAndCleansNames() { + val detail = ItemDetail( + contentId = "movie-1", + type = "MoViE", + title = "Movie", + crew = listOf( + CrewMember(name = " Alice ", job = " director "), + CrewMember(name = "Camera", job = "Director of Photography"), + CrewMember(name = "", job = "Director"), + CrewMember(name = "Alice", job = "DIRECTOR"), + CrewMember(name = "Bob", job = "Director"), + ), + ) + + assertEquals("Directed by Alice, Bob", movieDirectorCredit(detail)) + } + + @Test + fun movieCreditKeepsServerOrderAndCapsAtThreeNames() { + val detail = ItemDetail( + contentId = "movie-2", + type = "movie", + title = "Movie", + crew = listOf("One", "Two", "Three", "Four").map { + CrewMember(name = it, job = "Director") + }, + ) + + assertEquals("Directed by One, Two, Three", movieDirectorCredit(detail)) + } + + @Test + fun movieCreditIsAbsentForNonMoviesOrMissingDirectors() { + assertNull( + movieDirectorCredit( + ItemDetail( + contentId = "episode-1", + type = "episode", + title = "Episode", + crew = listOf(CrewMember(name = "Alice", job = "Director")), + ), + ), + ) + assertNull( + movieDirectorCredit( + ItemDetail( + contentId = "movie-3", + type = "movie", + title = "Movie", + crew = listOf(CrewMember(name = "Camera", job = "Cinematographer")), + ), + ), + ) + } +} diff --git a/androidApp/src/androidMain/AndroidManifest.xml b/androidApp/src/androidMain/AndroidManifest.xml index 7ab705e94..a2f8206d3 100644 --- a/androidApp/src/androidMain/AndroidManifest.xml +++ b/androidApp/src/androidMain/AndroidManifest.xml @@ -42,6 +42,7 @@ android:name=".MainActivity" android:exported="true" android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode" + android:launchMode="singleTop" android:supportsPictureInPicture="true" android:windowSoftInputMode="adjustResize"> @@ -56,6 +57,7 @@ + diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/MainActivity.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/MainActivity.kt index 140974604..1be27129f 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/MainActivity.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/MainActivity.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -32,16 +33,21 @@ import org.prairieserver.prairie.android.downloads.LEGACY_PUBLIC_DOWNLOAD_PERMIS import org.prairieserver.prairie.android.downloads.hasLegacyPublicDownloadPermission import org.prairieserver.prairie.android.push.PushNotificationPresenter import org.prairieserver.prairie.android.ui.navigation.AppNavigation +import org.prairieserver.prairie.android.ui.navigation.ExternalRouteRequest +import org.prairieserver.prairie.android.ui.navigation.ExternalRouteRequestFactory import org.prairieserver.prairie.android.ui.navigation.Route +import org.prairieserver.prairie.android.ui.navigation.clearConsumedExternalRouteRequest import org.prairieserver.prairie.android.ui.navigation.contentDeepLinkRouteOrNull import org.prairieserver.prairie.android.ui.navigation.deviceLoginPairRouteOrNull import org.prairieserver.prairie.android.ui.navigation.hasLocalDownloadsForScope +import org.prairieserver.prairie.android.ui.navigation.inviteClaimRouteOrNull import org.prairieserver.prairie.android.ui.navigation.notificationNavigationRouteOrNull import org.prairieserver.prairie.android.ui.navigation.shouldStartOnDownloads -import org.prairieserver.prairie.android.ui.theme.PrairieTheme +import org.prairieserver.prairie.android.ui.screens.onboarding.OnboardingTourLocalCache +import org.prairieserver.prairie.android.ui.theme.SiloTheme import org.prairieserver.prairie.common.network.ServerReachabilityMonitor -import org.prairieserver.prairie.common.pip.PrairiePictureInPictureCoordinator -import org.prairieserver.prairie.common.pip.PrairiePictureInPictureSurface +import org.prairieserver.prairie.common.pip.SiloPictureInPictureCoordinator +import org.prairieserver.prairie.common.pip.SiloPictureInPictureSurface import org.prairieserver.prairie.common.settings.PlayerSettingsStore import org.prairieserver.prairie.common.settings.ServerDrivenConfigRefresher import org.prairieserver.prairie.common.startup.StartupArtworkPlan @@ -57,7 +63,8 @@ import org.prairieserver.prairie.repository.ProfileRepository import org.prairieserver.prairie.repository.SectionRepository import org.prairieserver.prairie.repository.port.HomeCachePort import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.java.KoinJavaComponent.get @@ -72,7 +79,11 @@ class MainActivity : ComponentActivity() { private var hasShownColdSplash = false } - private val incomingExternalRoutes = MutableSharedFlow(extraBufferCapacity = 1) + private val externalRouteRequestFactory = ExternalRouteRequestFactory() + // Retain the latest request even while Compose is between collectors (for + // example while an existing top Activity is being resumed by onNewIntent). + // A replay-free SharedFlow can silently drop exactly that warm delivery. + private val pendingExternalRouteRequests = MutableStateFlow(null) // POST_NOTIFICATIONS is required on Android 13+ for any notification — // download progress / completion notifications silently never appear @@ -90,7 +101,7 @@ class MainActivity : ComponentActivity() { setContent { var startRoute by remember { mutableStateOf(null) } - var pendingExternalRoute by remember { mutableStateOf(null) } + val pendingExternalRoute by pendingExternalRouteRequests.collectAsState() var splashPlaybackComplete by remember { mutableStateOf(hasShownColdSplash) } LaunchedEffect(Unit) { @@ -102,16 +113,13 @@ class MainActivity : ComponentActivity() { // The pending route is only consumed once the main graph is // showing, so pre-auth starts just hold it. (notificationRouteOrNull(intent) ?: contentDeepLinkRouteOrNull(intent?.dataString)) - ?.let { pendingExternalRoute = it } + ?.let { route -> + pendingExternalRouteRequests.value = externalRouteRequestFactory.create(route) + } launchAuthenticatedStartupWarmup(route) } - LaunchedEffect(Unit) { - incomingExternalRoutes.collect { route -> - pendingExternalRoute = route - } - } - PrairieTheme { + SiloTheme { Surface(modifier = Modifier.fillMaxSize()) { val resolvedRoute = startRoute if (resolvedRoute == null || !splashPlaybackComplete) { @@ -143,7 +151,14 @@ class MainActivity : ComponentActivity() { AppNavigation( startDestination = resolvedRoute, pendingExternalRoute = pendingExternalRoute, - onExternalRouteConsumed = { pendingExternalRoute = null }, + onExternalRouteConsumed = { consumedRequest -> + pendingExternalRouteRequests.update { pendingRequest -> + clearConsumedExternalRouteRequest( + pendingRequest = pendingRequest, + consumedRequest = consumedRequest, + ) + } + }, ) } } @@ -164,9 +179,10 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) setIntent(intent) val route = deviceLoginPairRouteOrNull(intent.dataString) + ?: inviteClaimRouteOrNull(intent.dataString) ?: notificationRouteOrNull(intent) ?: contentDeepLinkRouteOrNull(intent.dataString) - route?.let { incomingExternalRoutes.tryEmit(it) } + route?.let { pendingExternalRouteRequests.value = externalRouteRequestFactory.create(it) } } /** @@ -187,8 +203,8 @@ class MainActivity : ComponentActivity() { override fun onUserLeaveHint() { super.onUserLeaveHint() - get(PrairiePictureInPictureCoordinator::class.java) - .enterPictureInPictureIfEligible(this, PrairiePictureInPictureSurface.Mobile) + get(SiloPictureInPictureCoordinator::class.java) + .enterPictureInPictureIfEligible(this, SiloPictureInPictureSurface.Mobile) } override fun onPictureInPictureModeChanged( @@ -196,7 +212,7 @@ class MainActivity : ComponentActivity() { newConfig: Configuration, ) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) - get(PrairiePictureInPictureCoordinator::class.java) + get(SiloPictureInPictureCoordinator::class.java) .setInPictureInPictureMode(isInPictureInPictureMode) } @@ -285,6 +301,16 @@ class MainActivity : ComponentActivity() { return Route.Downloads.route } + // A warm start would otherwise bypass the tour gate entirely (e.g. + // process death mid-tour). Once completion is confirmed the local + // cache short-circuits inside the gate, so this costs nothing on + // launches after the first; the gate itself fails open to Home on + // any error, so it can't strand an offline start. + val tourCache = get(OnboardingTourLocalCache::class.java) + if (!tourCache.isDone(activeEntry.id, profileId)) { + return Route.OnboardingTour.route + } + return Route.Home.route } @@ -311,6 +337,9 @@ class MainActivity : ComponentActivity() { personalDataRepository = get(PersonalDataRepository::class.java), sectionRepository = get(SectionRepository::class.java), homeCache = get(HomeCachePort::class.java), + identityTransitions = get( + org.prairieserver.prairie.network.IdentityTransitionBarrier::class.java, + ), serverUrl = get(ServerRegistry::class.java).activeEntry.value?.url, artworkPlan = StartupArtworkPlan.phone(), ) 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..607c19384 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 @@ -8,7 +8,7 @@ import org.prairieserver.prairie.common.downloads.DownloadSubscriptionWorker import org.prairieserver.prairie.common.downloads.OfflineMediaResolver import org.prairieserver.prairie.common.downloads.DownloadStorage import org.prairieserver.prairie.common.downloads.DownloadWorker -import org.prairieserver.prairie.common.cast.PrairieCastNsdBrowser +import org.prairieserver.prairie.common.cast.SiloCastNsdBrowser import org.prairieserver.prairie.common.pairing.PairingDeviceId import org.prairieserver.prairie.common.pairing.CompanionDeviceLoginApprover import org.prairieserver.prairie.common.pairing.CompanionPairingCoordinator @@ -20,7 +20,8 @@ import org.prairieserver.prairie.common.pairing.RepositoryCompanionDeviceLoginAp import org.prairieserver.prairie.common.pairing.TlsPskPairingClientTransport import org.prairieserver.prairie.common.player.AudioCapabilityManager import org.prairieserver.prairie.common.player.AudioTrackManager -import org.prairieserver.prairie.common.player.PrairiePlayerFactory +import org.prairieserver.prairie.common.player.AndroidSubtitlePresentation +import org.prairieserver.prairie.common.player.SiloPlayerFactory import org.prairieserver.prairie.common.player.PlaybackCapabilityDetector import org.prairieserver.prairie.common.player.PlaybackSessionManager import org.prairieserver.prairie.common.player.SubtitleManager @@ -61,7 +62,9 @@ import org.prairieserver.prairie.android.ui.screens.people.PersonDetailViewModel import org.prairieserver.prairie.android.ui.screens.auth.LoginViewModel import org.prairieserver.prairie.android.ui.screens.auth.ServerSetupViewModel import org.prairieserver.prairie.android.ui.screens.auth.SetupViewModel +import org.prairieserver.prairie.android.ui.screens.auth.InviteClaimViewModel import org.prairieserver.prairie.android.ui.screens.auth.SignupViewModel +import org.prairieserver.prairie.android.ui.screens.onboarding.OnboardingTourViewModel import org.prairieserver.prairie.android.ui.screens.MainHeaderViewModel import org.prairieserver.prairie.viewmodel.DevicePairingViewModel import org.prairieserver.prairie.android.ui.screens.profiles.CreateProfileViewModel @@ -70,6 +73,8 @@ import org.prairieserver.prairie.android.ui.screens.profiles.ProfileSelectionVie import org.prairieserver.prairie.android.ui.screens.servers.ServerListViewModel import org.prairieserver.prairie.android.ui.screens.downloads.DownloadsViewModel import org.prairieserver.prairie.viewmodel.HomeViewModel +import org.prairieserver.prairie.viewmodel.LiveTvViewModel +import org.prairieserver.prairie.viewmodel.LiveTvPlayerViewModel import org.prairieserver.prairie.android.ui.screens.libraries.LibrariesViewModel import org.prairieserver.prairie.viewmodel.FavoritesViewModel import org.prairieserver.prairie.viewmodel.HistoryViewModel @@ -85,17 +90,15 @@ import org.prairieserver.prairie.android.ui.screens.reading.ReadingHubViewModel import org.prairieserver.prairie.android.ui.screens.search.SearchViewModel import org.prairieserver.prairie.android.ui.screens.settings.SettingsViewModel import org.prairieserver.prairie.android.ui.screens.settings.diagnostics.DiagnosticsViewModel -import org.prairieserver.prairie.android.cast.SharedPrefsPrairieCastLastTargetStore -import org.prairieserver.prairie.android.cast.PrairieCastController -import org.prairieserver.prairie.android.cast.PrairieCastLastTargetStore -import org.prairieserver.prairie.android.cast.PrairieCastSessionManager +import org.prairieserver.prairie.android.cast.SharedPrefsSiloCastLastTargetStore +import org.prairieserver.prairie.android.cast.SiloCastController +import org.prairieserver.prairie.android.cast.SiloCastLastTargetStore +import org.prairieserver.prairie.android.cast.SiloCastSessionManager import org.koin.android.ext.koin.androidContext import org.koin.androidx.workmanager.dsl.worker import org.koin.core.module.dsl.viewModel import org.koin.core.qualifier.named import org.koin.dsl.module -import org.prairieserver.prairie.viewmodel.LiveTvPlayerViewModel -import org.prairieserver.prairie.viewmodel.LiveTvViewModel /** * Android-specific Koin module. @@ -119,13 +122,14 @@ val androidModule = module { // Persistent (EncryptedSharedPreferences-backed) replacement for the // commonMain in-memory TokenManager. Koin 3.1+ replaces same-key bindings // when the redefining module is loaded after the original — sharedModules() - // is registered first in PrairieApplication, so this wins. + // is registered first in SiloApplication, so this wins. single { EncryptedTokenManagerImpl(get(), get(), get()) } + single { org.prairieserver.prairie.android.ui.screens.onboarding.OnboardingTourLocalCache(androidContext()) } // Offline-first Room store (Track B). Bound after sharedModules() so the // commonMain PersonalDataRepository's `getOrNull()` picks // up the Room-backed port and writes optimistic projection + outbox rows. - single { org.prairieserver.prairie.common.data.db.PrairieDatabase.build(androidContext()) } + single { org.prairieserver.prairie.common.data.db.SiloDatabase.build(androidContext()) } single { val appContext = androidContext().applicationContext org.prairieserver.prairie.common.data.sync.OutboxSyncScheduler { @@ -146,6 +150,7 @@ val androidModule = module { org.prairieserver.prairie.common.data.repository.RoomHomeCacheRepository( db = get(), snapshotProvider = { tokenManager.snapshotCurrentScope() }, + identityTransitions = get(), ) } single { @@ -153,6 +158,7 @@ val androidModule = module { org.prairieserver.prairie.common.data.repository.RoomCatalogCacheRepository( db = get(), snapshotProvider = { tokenManager.snapshotCurrentScope() }, + identityTransitions = get(), ) } single { @@ -176,7 +182,7 @@ val androidModule = module { single { AndroidDeviceMetadataProvider(androidContext(), platform = "android") } - single { PrairieCastNsdBrowser(androidContext()) } + single { SiloCastNsdBrowser(androidContext()) } single { CompanionPairingNsdBrowser(androidContext()) } single { RegistryCompanionPairingServerStore(get(), get()) } single { RepositoryCompanionDeviceLoginApprover(get()) } @@ -186,9 +192,9 @@ val androidModule = module { } } single { CompanionPairingCoordinator(get(), get(), get()) } - single { SharedPrefsPrairieCastLastTargetStore(androidContext()) } + single { SharedPrefsSiloCastLastTargetStore(androidContext()) } single { - PrairieCastController( + SiloCastController( browser = get(), serverRegistry = get(), tokenManager = get(), @@ -217,7 +223,12 @@ val androidModule = module { single { PushMessageHandler(presenter = get()) } // Player infrastructure - single { SubtitleManager(get()) } + single { + SubtitleManager( + libassBridge = get(), + presentation = AndroidSubtitlePresentation.Phone, + ) + } single { AudioTrackManager() } single { VideoPlaybackBackendFactory( @@ -229,7 +240,7 @@ val androidModule = module { single { AudioCapabilityManager(androidContext()) } single { PlaybackCapabilityDetector(androidContext(), get(), get()) } single { - PrairiePlayerFactory( + SiloPlayerFactory( context = androidContext(), tokenManager = get(), subtitleManager = get(), @@ -245,7 +256,7 @@ val androidModule = module { // Google Cast (Chromecast) — phone only. The session manager owns the Cast // SDK lifecycle; the preparer opens the separate Tier-2 cast-capability // playback session so the raw phone stream is never cast. - single { PrairieCastSessionManager(androidContext()) } + single { SiloCastSessionManager(androidContext()) } single { CastPlaybackPreparer( playbackRepository = get(), @@ -282,7 +293,7 @@ val androidModule = module { single { DownloadEnqueuer(androidContext(), get(), get(), get(), get(), get(), get(), get()) } single { DownloadSubscriptionEvaluatorFactory(get(), get(), get()) } // CoroutineWorker constructed by Koin's WorkerFactory — see - // PrairieApplication.onCreate `workManagerFactory()` call. + // SiloApplication.onCreate `workManagerFactory()` call. worker { DownloadWorker( appContext = androidContext(), @@ -338,7 +349,7 @@ val androidModule = module { castPlaybackPreparer = get(), ) } - viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull()) } + viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull(), get()) } viewModel { MainHeaderViewModel(get()) } viewModel { LibrariesViewModel( @@ -398,7 +409,7 @@ val androidModule = module { tmdbId = args.second, ) } - viewModel { SettingsViewModel(get(), get(), get(), get(), get(), get()) } + viewModel { SettingsViewModel(get(), get(), get(), get(), get(), get(), get()) } viewModel { DiagnosticsViewModel(get()) } viewModel { AdminEntryViewModel(get(), get()) } viewModel { AdminStatsViewModel(get()) } @@ -413,6 +424,8 @@ val androidModule = module { viewModel { LoginViewModel(get()) } viewModel { SetupViewModel(get()) } viewModel { SignupViewModel(get()) } + viewModel { InviteClaimViewModel(get(), get()) } + viewModel { OnboardingTourViewModel(get(), get(), get(), get(), get()) } viewModel { ProfileSelectionViewModel(get()) } viewModel { CreateProfileViewModel(get()) } viewModel { EditProfileViewModel(get()) } @@ -445,6 +458,7 @@ val androidModule = module { org.prairieserver.prairie.common.player.AudiobookPlayerViewModel( catalogRepository = get(), playbackSessionManager = get(), + playbackSessionLifecycle = get(), capabilityDetector = get(), bookmarksStore = get(), userItemStatePort = get(), diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MainAppTopBar.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MainAppTopBar.kt index b192265f4..a9a9952d5 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MainAppTopBar.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MainAppTopBar.kt @@ -54,13 +54,13 @@ fun MainAppTopBar( isProfileLoading: Boolean, onSearchClick: () -> Unit, onRequestsClick: (() -> Unit)? = null, - onLiveTvClick: (() -> Unit)? = null, + onWatchTogetherClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, onSignOutClick: () -> Unit, leadingContent: @Composable () -> Unit = { - PrairieWordmark() + SiloWordmark() }, ) { var menuExpanded by rememberSaveable { mutableStateOf(false) } @@ -149,18 +149,16 @@ fun MainAppTopBar( }, ) } - if (onLiveTvClick != null) { + if (onWatchTogetherClick != null) { DropdownMenuItem( - text = { Text("Live TV") }, + text = { Text("Watch Together") }, onClick = { menuExpanded = false - onLiveTvClick() + onWatchTogetherClick() }, ) } - if (onRequestsClick != null || onLiveTvClick != null) { - HorizontalDivider() - } + HorizontalDivider() DropdownMenuItem( text = { Text("Settings") }, onClick = { @@ -230,12 +228,12 @@ private fun HeaderActionButton( } @Composable -fun PrairieWordmark( +fun SiloWordmark( modifier: Modifier = Modifier, width: Dp = 72.dp, ) { androidx.compose.foundation.Image( - painter = painterResource(id = R.drawable.prairie_wordmark), + painter = painterResource(id = R.drawable.silo_wordmark), contentDescription = "Prairie", contentScale = ContentScale.Fit, modifier = modifier diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/aurora/AuroraChrome.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/aurora/AuroraChrome.kt index 9aabdfe63..117c5d9e7 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/aurora/AuroraChrome.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/aurora/AuroraChrome.kt @@ -131,10 +131,20 @@ fun AuroraErrorLabel(text: String, modifier: Modifier = Modifier) { * sheen + soft drop shadow; optional gold halo). Compose has no backdrop blur, * so the tint is kept translucent enough for the aurora to glow through. */ -fun Modifier.auroraGlass(cornerRadius: Dp = 28.dp, emphasized: Boolean = false): Modifier { +fun Modifier.auroraGlass( + cornerRadius: Dp = 28.dp, + emphasized: Boolean = false, + /** + * The drop shadow reads as depth under a small panel floating on a static + * screen. Pass 0.dp for a large or moving panel: the fill is translucent, + * so at full size the shadow's own outline shows *through* the glass as a + * faint hard-edged box rather than sitting behind it. + */ + elevation: Dp = 60.dp, +): Modifier { val shape = RoundedCornerShape(cornerRadius) return this - .shadow(elevation = 60.dp, shape = shape, clip = false) + .then(if (elevation > 0.dp) Modifier.shadow(elevation, shape, clip = false) else Modifier) .clip(shape) .background(AuroraGlassTint.copy(alpha = 0.62f)) .background( diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/aurora/AuroraScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/aurora/AuroraScreen.kt index 5027701f9..2798c3e65 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/aurora/AuroraScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/aurora/AuroraScreen.kt @@ -1,31 +1,54 @@ package org.prairieserver.prairie.android.ui.components.aurora +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp /** - * Aurora backdrop + a vertically scrollable, keyboard-friendly column capped to - * a comfortable reading width and centered over the plum backdrop. Callers - * supply the wordmark + content. Port of prairie-apple's `AuroraScreen`. + * Aurora backdrop + a keyboard-friendly column capped to a comfortable reading + * width and centered over the plum backdrop. Callers supply the wordmark + + * content. Port of prairie-apple's `AuroraScreen`. */ @Composable fun AuroraScreen( variant: AuroraVariant, modifier: Modifier = Modifier, scrim: AuroraScrim = AuroraScrim.Soft, - maxContentWidth: androidx.compose.ui.unit.Dp = 480.dp, + maxContentWidth: Dp = 480.dp, + /** + * Scrolls the content as one block and sizes it to its children — right for + * the form screens, which are shorter than the display until the keyboard + * opens. + * + * Screens that lay themselves out against the full height (a `weight`ed + * body between fixed chrome) must pass `false`. A scrolling parent measures + * its children with unbounded height, which leaves nothing for `weight` to + * divide, so weighted children collapse to zero and vanish. + */ + scrollable: Boolean = true, + /** + * Gutter between the content and the display edge. Screens that run a + * full-bleed element (a pager whose neighbouring pages should peek past the + * gutter rather than be clipped at it) set this to zero and pad their own + * rows instead. + */ + horizontalPadding: Dp = 24.dp, content: @Composable ColumnScope.() -> Unit, ) { Box(modifier = modifier.fillMaxSize()) { @@ -33,16 +56,21 @@ fun AuroraScreen( Column( modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) - .imePadding(), + .then(if (scrollable) Modifier.verticalScroll(rememberScrollState()) else Modifier) + // safeDrawing covers the status/navigation bars, the cutout and + // the IME — the activity draws edge to edge, so without this the + // first and last rows of a full-height screen sit under system + // chrome. + .windowInsetsPadding(WindowInsets.safeDrawing), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center, + verticalArrangement = Arrangement.Center, ) { Column( modifier = Modifier .fillMaxWidth() .widthIn(max = maxContentWidth) - .padding(horizontal = 24.dp, vertical = 32.dp), + .then(if (scrollable) Modifier else Modifier.fillMaxHeight()) + .padding(horizontal = horizontalPadding, vertical = 32.dp), horizontalAlignment = Alignment.CenterHorizontally, content = content, ) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt index 0eb444197..f9ca80406 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt @@ -30,17 +30,20 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navDeepLink import androidx.navigation.navArgument +import kotlinx.coroutines.flow.map import org.prairieserver.prairie.android.cast.GoogleCastMiniBar -import org.prairieserver.prairie.android.cast.PrairieCastController -import org.prairieserver.prairie.android.cast.PrairieCastSessionManager -import org.prairieserver.prairie.android.ui.screens.cast.PrairieCastMiniBar -import org.prairieserver.prairie.android.ui.screens.cast.PrairieCastRemoteScreen +import org.prairieserver.prairie.android.cast.SiloCastController +import org.prairieserver.prairie.android.cast.SiloCastSessionManager +import org.prairieserver.prairie.android.ui.screens.cast.SiloCastMiniBar +import org.prairieserver.prairie.android.ui.screens.cast.SiloCastRemoteScreen import org.prairieserver.prairie.android.ui.screens.MainScreen import org.prairieserver.prairie.android.ui.screens.auth.LoginScreen import org.prairieserver.prairie.android.ui.screens.auth.DevicePairingScreen import org.prairieserver.prairie.android.ui.screens.auth.ServerSetupScreen import org.prairieserver.prairie.android.ui.screens.auth.SetupScreen +import org.prairieserver.prairie.android.ui.screens.auth.InviteClaimScreen import org.prairieserver.prairie.android.ui.screens.auth.SignupScreen +import org.prairieserver.prairie.android.ui.screens.onboarding.OnboardingTourScreen import org.prairieserver.prairie.android.ui.screens.browse.BrowseScreen import org.prairieserver.prairie.android.ui.screens.browse.BrowseViewModel import org.prairieserver.prairie.android.ui.screens.calendar.CalendarScreen @@ -58,14 +61,14 @@ import org.prairieserver.prairie.android.ui.screens.personal.FavoritesScreen import org.prairieserver.prairie.android.ui.screens.personal.HistoryScreen import org.prairieserver.prairie.android.ui.screens.personal.PersonalListsScreen import org.prairieserver.prairie.android.ui.screens.personal.WatchlistScreen +import org.prairieserver.prairie.android.ui.screens.player.MobilePlayerRouteTarget import org.prairieserver.prairie.android.ui.screens.player.PlayerScreen +import org.prairieserver.prairie.android.ui.screens.player.PlayerViewModel import org.prairieserver.prairie.android.ui.screens.profiles.CreateProfileScreen import org.prairieserver.prairie.android.ui.screens.profiles.EditProfileScreen import org.prairieserver.prairie.android.ui.screens.profiles.ProfileSelectionScreen import org.prairieserver.prairie.android.ui.screens.requests.MyRequestsScreen import org.prairieserver.prairie.android.ui.screens.requests.RequestDetailScreen -import org.prairieserver.prairie.android.ui.screens.livetv.LiveTvPlayerScreen -import org.prairieserver.prairie.android.ui.screens.livetv.LiveTvScreen import org.prairieserver.prairie.android.ui.screens.requests.RequestsScreen import org.prairieserver.prairie.android.ui.screens.search.MobileSearchMediaType import org.prairieserver.prairie.android.ui.screens.search.SearchScreen @@ -79,7 +82,7 @@ import org.prairieserver.prairie.common.diagnostics.DiagnosticsLifecycleLogger import org.prairieserver.prairie.android.ui.screens.settings.diagnostics.DiagnosticsReportScreen import org.prairieserver.prairie.android.ui.screens.settings.diagnostics.DiagnosticsSettingsScreen import org.prairieserver.prairie.android.ui.screens.settings.diagnostics.DiagnosticsViewModel -import org.prairieserver.prairie.cast.PrairieCastPlaybackRequest +import org.prairieserver.prairie.cast.SiloCastPlaybackRequest import org.prairieserver.prairie.common.overlays.ProvideCardOverlays import org.prairieserver.prairie.common.player.video.VideoPlayerRouteArgs import org.prairieserver.prairie.common.settings.OverlayPrefsStore @@ -90,23 +93,41 @@ import org.koin.compose.viewmodel.koinViewModel /** Page-to-page cross-fade duration (ms). Snappier than Compose Nav's 700ms default. */ private const val PageFadeDurationMs = 200 +internal class PlayerTargetProviderRegistration( + val backStackEntryId: String, + val target: () -> MobilePlayerRouteTarget?, +) + +internal fun currentPlayerTargetOrNull( + currentBackStackEntryId: String?, + registration: PlayerTargetProviderRegistration?, +): MobilePlayerRouteTarget? { + if (currentBackStackEntryId == null || registration?.backStackEntryId != currentBackStackEntryId) { + return null + } + return registration.target() +} + @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun AppNavigation( navController: NavHostController = rememberNavController(), startDestination: String = Route.Login.route, - pendingExternalRoute: String? = null, - onExternalRouteConsumed: () -> Unit = {}, + pendingExternalRoute: ExternalRouteRequest? = null, + onExternalRouteConsumed: (ExternalRouteRequest) -> Unit = {}, ) { val tokenManager: TokenManager = koinInject() val overlayPrefsStore: OverlayPrefsStore = koinInject() - val prairieCastController: PrairieCastController = koinInject() + val siloCastController: SiloCastController = koinInject() val diagnosticsViewModel = koinViewModel() val diagnosticsState by diagnosticsViewModel.state.collectAsState() + var activePlayerTargetProvider by remember { + mutableStateOf(null) + } - DisposableEffect(prairieCastController) { - prairieCastController.startBrowsing() - onDispose { prairieCastController.stopBrowsing() } + DisposableEffect(siloCastController) { + siloCastController.startBrowsing() + onDispose { siloCastController.stopBrowsing() } } // Graceful handling of server-side session invalidation (refresh 401'd). @@ -122,37 +143,38 @@ fun AppNavigation( } } - // Keyed on the route so a notification arriving later restarts the - // collection; currentBackStackEntryFlow emits the current entry - // immediately on collect, so both "route arrives while on Main" and - // "Main arrives with route queued" are covered. - LaunchedEffect(pendingExternalRoute) { - // Consume only while the main (authenticated) graph is showing — - // a notification tapped pre-sign-in stays queued until auth lands, - // instead of pushing its target over Login. The back-stack flow makes - // this re-fire when Main arrives with the route still pending. - navController.currentBackStackEntryFlow.collect { entry -> - val route = pendingExternalRoute?.takeIf { it.isNotBlank() } ?: return@collect - // Every pre-auth / onboarding destination — a notification tapped - // on any of these stays queued until the authenticated graph - // shows, instead of pushing a content route that would 401. - val authRoutes = setOf( - Route.Login.route, - Route.ServerSetup.route, - Route.ServerList.ROUTE, - Route.Setup.route, - Route.Signup.route, - Route.ProfileSelection.route, - Route.CreateProfile.route, - Route.EditProfile.ROUTE, - Route.PairDevice.ROUTE, - ) - if (entry.destination.route in authRoutes) return@collect - navController.navigate(route) { - launchSingleTop = true - } - onExternalRouteConsumed() - } + // Keyed on request identity, not route text, so delivering the same deep + // link again after Back still restarts the wait. The back-stack flow emits + // the current entry immediately, covering both "request arrives on Main" + // and "Main arrives with a request queued". Delivery itself is one-shot. + LaunchedEffect(pendingExternalRoute?.generation) { + consumeExternalRouteOnce( + pendingExternalRoute = pendingExternalRoute, + currentDestinationRoutes = navController.currentBackStackEntryFlow + .map { entry -> entry.destination.route }, + isAlreadyAtRoute = { route -> + navController.isDisplayingExactPlayerRoute( + route = route, + currentPlayerTarget = currentPlayerTargetOrNull( + currentBackStackEntryId = navController.currentBackStackEntry?.id, + registration = activePlayerTargetProvider, + ), + ) + }, + navigate = { route -> + val replaceCurrentPlayer = shouldReplaceCurrentPlayer( + currentDestinationRoute = navController.currentBackStackEntry?.destination?.route, + targetRoute = route, + ) + navController.navigate(route) { + if (replaceCurrentPlayer) { + popUpTo(Route.Player.ROUTE) { inclusive = true } + } + launchSingleTop = true + } + }, + onConsumed = onExternalRouteConsumed, + ) } // Re-read the authenticated profile id whenever the current destination @@ -226,9 +248,8 @@ fun AppNavigation( } }, onChangeServer = { - navController.navigate(Route.ServerList.autoScanRoute(autoScan = true)) { + navController.navigate(Route.ServerSetup.route) { popUpTo(Route.Login.route) { inclusive = true } - launchSingleTop = true } }, ) @@ -254,6 +275,44 @@ fun AppNavigation( }, ) } + composable( + route = Route.InviteClaim.ROUTE, + arguments = listOf( + navArgument("server") { type = NavType.StringType }, + navArgument("token") { type = NavType.StringType }, + ), + deepLinks = listOf( + navDeepLink { uriPattern = "prairie://invite?server={server}&token={token}" }, + ), + ) { backStackEntry -> + val server = backStackEntry.arguments?.getString("server").orEmpty() + val claimToken = backStackEntry.arguments?.getString("token").orEmpty() + InviteClaimScreen( + serverUrl = server, + token = claimToken, + onNavigateToLogin = { + navController.navigate(Route.Login.route) { + popUpTo(0) { inclusive = true } + } + }, + onClaimComplete = { + navController.navigate(Route.ProfileSelection.route) { + popUpTo(0) { inclusive = true } + } + }, + ) + } + + composable(Route.OnboardingTour.route) { + OnboardingTourScreen( + onDone = { + navController.navigate(Route.Home.route) { + popUpTo(0) { inclusive = true } + } + }, + ) + } + composable( route = Route.PairDevice.ROUTE, arguments = listOf( @@ -291,22 +350,9 @@ fun AppNavigation( ) } - // ---- Server list (first-run connect + multi-server management) ---- - composable( - route = Route.ServerList.ROUTE, - arguments = listOf( - navArgument(Route.ServerList.ARG_AUTO_SCAN) { - type = NavType.BoolType - defaultValue = false - }, - ), - ) { backStackEntry -> - val autoScan = backStackEntry.arguments - ?.getBoolean(Route.ServerList.ARG_AUTO_SCAN) - ?: false - val canGoBack = navController.previousBackStackEntry != null + // ---- Server list (multi-server management) ---- + composable(Route.ServerList.route) { ServerListScreen( - autoScan = autoScan, onAddServer = { navController.navigate(Route.ServerSetup.route) }, @@ -314,23 +360,21 @@ fun AppNavigation( // Route to whichever screen the new server's stored // credentials can support — Home if a token+profile // already exist (so the user stays signed in), else - // ProfileSelection, Login, or Setup as appropriate. + // ProfileSelection or Login as appropriate. val target = when (destination) { - ServerSwitchDestination.Home -> Route.Home.route + // Through the tour gate, not straight to Home — the + // switched-to server's profile may not have seen the + // tour; the gate short-circuits when it has. + ServerSwitchDestination.Home -> Route.OnboardingTour.route ServerSwitchDestination.ProfileSelection -> Route.ProfileSelection.route ServerSwitchDestination.Login -> Route.Login.route - ServerSwitchDestination.Setup -> Route.Setup.route } navController.navigate(target) { popUpTo(0) { inclusive = true } launchSingleTop = true } }, - onBack = if (canGoBack) { - { navController.popBackStack() } - } else { - null - }, + onBack = { navController.popBackStack() }, ) } @@ -338,7 +382,10 @@ fun AppNavigation( composable(Route.ProfileSelection.route) { ProfileSelectionScreen( onNavigateToHome = { - navController.navigate(Route.Home.route) { + // Route through the tour gate: OnboardingTourScreen checks + // server-side state and immediately hands off to Home when + // the profile has already completed or skipped the tour. + navController.navigate(Route.OnboardingTour.route) { popUpTo(0) { inclusive = true } } }, @@ -418,7 +465,7 @@ fun AppNavigation( composable(Route.Settings.route) { SettingsScreen( onNavigateToServers = { - navController.navigate(Route.ServerList.autoScanRoute(autoScan = false)) + navController.navigate(Route.ServerList.route) }, onPairDevice = { navController.navigate(Route.PairDevice().route) @@ -469,8 +516,8 @@ fun AppNavigation( onBackClick = { navController.popBackStack() }, ) } - composable(Route.PrairieCastRemote.route) { - PrairieCastRemoteScreen(onBack = { navController.popBackStack() }) + composable(Route.SiloCastRemote.route) { + SiloCastRemoteScreen(onBack = { navController.popBackStack() }) } composable( route = Route.Search.ROUTE, @@ -524,40 +571,6 @@ fun AppNavigation( }, ) } - - // ---- Live TV ---- - composable(Route.LiveTv.route) { - LiveTvScreen( - onBackClick = { navController.popBackStack() }, - onChannelClick = { channel -> - navController.navigate( - Route.LiveTvPlayer(channel.id, channel.displayName).route, - ) - }, - onPlayLibraryItem = { contentId -> - navController.navigate(Route.ItemDetail(contentId).route) - }, - ) - } - composable( - route = Route.LiveTvPlayer.ROUTE, - arguments = listOf( - navArgument(Route.LiveTvPlayer.ARG_CHANNEL_ID) { type = NavType.StringType }, - navArgument(Route.LiveTvPlayer.ARG_NAME) { - type = NavType.StringType - nullable = true - defaultValue = "" - }, - ), - ) { backStackEntry -> - val channelId = backStackEntry.arguments?.getString(Route.LiveTvPlayer.ARG_CHANNEL_ID).orEmpty() - val channelName = backStackEntry.arguments?.getString(Route.LiveTvPlayer.ARG_NAME).orEmpty() - LiveTvPlayerScreen( - channelId = channelId, - channelName = channelName, - onBackClick = { navController.popBackStack() }, - ) - } composable( route = Route.RequestDetail.ROUTE, arguments = listOf( @@ -641,8 +654,8 @@ fun AppNavigation( ItemDetailScreen( onBackClick = { navController.popBackStack() }, onPlayClick = { contentId, fileId, audioTrackIndex, subtitleTrackIndex, resumePositionSeconds -> - val launchedRemotely = prairieCastController.launchOnConnectedTarget( - PrairieCastPlaybackRequest( + val launchedRemotely = siloCastController.launchOnConnectedTarget( + SiloCastPlaybackRequest( contentId = contentId, fileId = fileId, audioTrackIndex = audioTrackIndex, @@ -652,7 +665,7 @@ fun AppNavigation( ), ) if (launchedRemotely) { - navController.navigate(Route.PrairieCastRemote.route) { launchSingleTop = true } + navController.navigate(Route.SiloCastRemote.route) { launchSingleTop = true } } else { navController.navigate( Route.Player( @@ -689,7 +702,7 @@ fun AppNavigation( }, onWatchTogether = { contentId, fileId -> wtTarget = contentId to fileId }, onOpenCastRemote = { - navController.navigate(Route.PrairieCastRemote.route) { launchSingleTop = true } + navController.navigate(Route.SiloCastRemote.route) { launchSingleTop = true } }, viewModel = detailViewModel, ) @@ -821,6 +834,19 @@ fun AppNavigation( }, ), ) { backStackEntry -> + val playerViewModel = koinViewModel() + DisposableEffect(backStackEntry.id, playerViewModel) { + val registration = PlayerTargetProviderRegistration( + backStackEntryId = backStackEntry.id, + target = playerViewModel::currentExternalRouteTarget, + ) + activePlayerTargetProvider = registration + onDispose { + if (activePlayerTargetProvider === registration) { + activePlayerTargetProvider = null + } + } + } PlayerScreen( contentId = backStackEntry.arguments?.getString("contentId") ?: "", initialFileId = backStackEntry.arguments?.getString("fileId")?.toIntOrNull(), @@ -834,6 +860,7 @@ fun AppNavigation( ), roomId = backStackEntry.arguments?.getString("roomId"), navController = navController, + viewModel = playerViewModel, ) } @@ -932,13 +959,13 @@ fun AppNavigation( Route.Recommendations.route, Route.Downloads.route, Route.Calendar.route, - Route.PrairieCastRemote.route, + Route.SiloCastRemote.route, Route.Player.ROUTE, ) if (currentRoute !in castBarInlineRoutes) { - PrairieCastMiniBar( - controller = prairieCastController, - onOpenRemote = { navController.navigate(Route.PrairieCastRemote.route) }, + SiloCastMiniBar( + controller = siloCastController, + onOpenRemote = { navController.navigate(Route.SiloCastRemote.route) }, modifier = Modifier .align(Alignment.BottomCenter) .navigationBarsPadding(), @@ -948,7 +975,7 @@ fun AppNavigation( // Google Cast (Chromecast) mini controller — app-wide except the player, // which shows the full cast takeover overlay instead. On tab routes it // stacks above MainScreen's bottom nav menu. - val googleCastManager: PrairieCastSessionManager = koinInject() + val googleCastManager: SiloCastSessionManager = koinInject() val googleCastState by googleCastManager.castState.collectAsState() if (googleCastState.isConnected && currentRoute != Route.Player.ROUTE) { val tabRoutes = setOf( @@ -976,3 +1003,23 @@ fun AppNavigation( } } } + +/** + * Exact player redelivery is idempotent. Navigating the same concrete route + * with launchSingleTop replaces the top entry and tears down active playback; + * a different content/file/quality/track route must still navigate normally. + */ +private fun NavHostController.isDisplayingExactPlayerRoute( + route: String, + currentPlayerTarget: MobilePlayerRouteTarget?, +): Boolean { + val entry = currentBackStackEntry ?: return false + if (entry.destination.route != Route.Player.ROUTE) return false + val arguments = entry.arguments ?: return false + // A normal prairie://play link is a solo-playback request. Never swallow it + // merely because a Watch Together room currently happens to play the same + // content/file. + if (!arguments.getString("roomId").isNullOrBlank()) return false + val requestedTarget = playerRouteIntentOrNull(route) ?: return false + return currentPlayerTarget?.let(requestedTarget::matches) == true +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginRouteParser.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginRouteParser.kt index b1337fda4..caa8933c9 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginRouteParser.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginRouteParser.kt @@ -45,9 +45,11 @@ private fun URI.queryParameters(): Map = .mapNotNull { pair -> val idx = pair.indexOf("=") if (idx < 0) return@mapNotNull null - val key = pair.substring(0, idx).urlDecode() - val value = pair.substring(idx + 1).urlDecode() - key to value + // Reachable from onNewIntent with URIs other apps craft; bad + // percent-encoding must parse to null, not throw. + runCatching { + pair.substring(0, idx).urlDecode() to pair.substring(idx + 1).urlDecode() + }.getOrNull() } .toMap() diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/ExternalRouteNavigation.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/ExternalRouteNavigation.kt new file mode 100644 index 000000000..8c4e8712c --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/ExternalRouteNavigation.kt @@ -0,0 +1,164 @@ +package org.prairieserver.prairie.android.ui.navigation + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import org.prairieserver.prairie.android.ui.screens.player.MobilePlayerRouteIntent +import org.prairieserver.prairie.android.ui.screens.player.MobilePlayerRouteTarget +import org.prairieserver.prairie.common.player.video.VideoPlayerRouteArgs + +/** A single external-navigation delivery, distinct even when its route repeats. */ +class ExternalRouteRequest internal constructor( + val generation: Long, + val route: String, +) + +internal class ExternalRouteRequestFactory { + private var latestGeneration = 0L + + fun create(route: String): ExternalRouteRequest = + ExternalRouteRequest( + generation = ++latestGeneration, + route = route, + ) +} + +internal fun clearConsumedExternalRouteRequest( + pendingRequest: ExternalRouteRequest?, + consumedRequest: ExternalRouteRequest, +): ExternalRouteRequest? = + if (pendingRequest?.generation == consumedRequest.generation) null else pendingRequest + +internal fun shouldReplaceCurrentPlayer( + currentDestinationRoute: String?, + targetRoute: String, +): Boolean = + currentDestinationRoute == Route.Player.ROUTE && targetRoute.startsWith("player/") + +/** Parses the canonical in-app player route carried by an external request. */ +internal fun playerRouteIntentOrNull(route: String): MobilePlayerRouteIntent? { + if (!route.startsWith("player/")) return null + val contentId = route + .substringAfter("player/") + .substringBefore('?') + .takeIf { it.isNotBlank() } + ?: return null + val query = route + .substringAfter('?', "") + .split('&') + .filter(String::isNotBlank) + .associate { part -> part.substringBefore('=') to part.substringAfter('=', "") } + // This provider describes solo playback only. A room-scoped target must be + // handled by Watch Together even if every media choice happens to match. + if ("roomId" in query) return null + + val fileId = query["fileId"]?.toIntOrNull() + if ("fileId" in query && (fileId == null || fileId <= 0)) return null + val quality = query[VideoPlayerRouteArgs.QUALITY] + ?.let(VideoPlayerRouteArgs::normalizeQuality) + if (VideoPlayerRouteArgs.QUALITY in query && quality == null) return null + val audioTrackIndex = query["audioTrackIndex"]?.toIntOrNull() + if ("audioTrackIndex" in query && (audioTrackIndex == null || audioTrackIndex < 0)) return null + val subtitleTrackIndex = query["subtitleTrackIndex"]?.toIntOrNull() + if ("subtitleTrackIndex" in query && (subtitleTrackIndex == null || subtitleTrackIndex < -1)) return null + val resumePositionSeconds = query[VideoPlayerRouteArgs.RESUME_POSITION] + ?.let(VideoPlayerRouteArgs::parseResumePosition) + if (VideoPlayerRouteArgs.RESUME_POSITION in query && resumePositionSeconds == null) return null + + return MobilePlayerRouteIntent( + contentId = contentId, + fileId = fileId, + quality = quality, + audioTrackIndex = audioTrackIndex, + subtitleTrackIndex = subtitleTrackIndex, + resumePositionSeconds = resumePositionSeconds, + fileIsExplicit = "fileId" in query, + qualityIsExplicit = VideoPlayerRouteArgs.QUALITY in query, + audioTrackIsExplicit = "audioTrackIndex" in query, + subtitleTrackIsExplicit = "subtitleTrackIndex" in query, + ) +} + +/** + * Tests an incoming route against both the current route intent and live + * playback values. An omitted parameter remains exact only while the player is + * still using automatic selection; an explicit parameter compares to the live + * resolved value. + */ +internal fun MobilePlayerRouteIntent.matches(target: MobilePlayerRouteTarget): Boolean = + contentId == target.contentId && + resumePositionSeconds == target.resumePositionSeconds && + optionalTargetMatches( + requestedIsExplicit = fileIsExplicit, + requestedValue = fileId, + currentIsExplicit = target.intent.fileIsExplicit, + liveValue = target.fileId, + ) && + optionalTargetMatches( + requestedIsExplicit = qualityIsExplicit, + requestedValue = quality, + currentIsExplicit = target.intent.qualityIsExplicit, + liveValue = target.quality, + ) && + optionalTargetMatches( + requestedIsExplicit = audioTrackIsExplicit, + requestedValue = audioTrackIndex, + currentIsExplicit = target.intent.audioTrackIsExplicit, + liveValue = target.audioTrackIndex, + ) && + optionalTargetMatches( + requestedIsExplicit = subtitleTrackIsExplicit, + requestedValue = subtitleTrackIndex, + currentIsExplicit = target.intent.subtitleTrackIsExplicit, + liveValue = target.subtitleTrackIndex, + ) + +private fun optionalTargetMatches( + requestedIsExplicit: Boolean, + requestedValue: T?, + currentIsExplicit: Boolean, + liveValue: T?, +): Boolean = if (requestedIsExplicit) { + requestedValue != null && requestedValue == liveValue +} else { + !currentIsExplicit +} + +private val preAuthenticationDestinationRoutes = setOf( + Route.Login.route, + Route.ServerSetup.route, + Route.ServerList.route, + Route.Setup.route, + Route.Signup.route, + Route.ProfileSelection.route, + Route.CreateProfile.route, + Route.EditProfile.ROUTE, + Route.PairDevice.ROUTE, + Route.InviteClaim.ROUTE, + Route.OnboardingTour.route, +) + +/** + * Waits until [pendingExternalRoute] is allowed from the current graph, then + * delivers it exactly once. [first] ends the back-stack subscription before + * [navigate] can emit the destination it just added; navigating from inside a + * long-lived collector otherwise feeds that new entry back into the same route. + */ +internal suspend fun consumeExternalRouteOnce( + pendingExternalRoute: ExternalRouteRequest?, + currentDestinationRoutes: Flow, + isAlreadyAtRoute: (String) -> Boolean = { false }, + navigate: (String) -> Unit, + onConsumed: (ExternalRouteRequest) -> Unit, +) { + val request = pendingExternalRoute ?: return + val route = request.route.takeIf { it.isNotBlank() } ?: return + val isPreAuthenticationTarget = route.startsWith("invite_claim") + + currentDestinationRoutes.first { currentRoute -> + isPreAuthenticationTarget || currentRoute !in preAuthenticationDestinationRoutes + } + if (!isAlreadyAtRoute(route)) { + navigate(route) + } + onConsumed(request) +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/InviteClaimRouteParser.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/InviteClaimRouteParser.kt new file mode 100644 index 000000000..90ca35379 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/InviteClaimRouteParser.kt @@ -0,0 +1,49 @@ +package org.prairieserver.prairie.android.ui.navigation + +import java.net.URI +import java.net.URLDecoder +import java.net.URLEncoder + +/** + * Maps an emailed-invitation link (`prairie://invite?server=...&token=...`) + * into the in-app claim route. + * + * Cold starts match through the composable's navDeepLink; this exists for + * the warm path — an intent delivered to a live activity via onNewIntent + * never reaches navDeepLink matching, and without this the tap would be + * silently dropped. + */ +internal fun inviteClaimRouteOrNull(rawUri: String?): String? { + val uri = rawUri + ?.takeIf { it.isNotBlank() } + ?.let { runCatching { URI(it) }.getOrNull() } + ?: return null + + if (!uri.scheme.equals("prairie", ignoreCase = true)) return null + if (!uri.host.equals("invite", ignoreCase = true)) return null + + // Any other app can hand the exported activity a malformed URI via + // onNewIntent; bad percent-encoding must parse to null, not throw. + val params = uri.rawQuery + .orEmpty() + .split("&") + .filter { it.isNotBlank() } + .mapNotNull { pair -> + val idx = pair.indexOf("=") + if (idx < 0) return@mapNotNull null + runCatching { + val key = URLDecoder.decode(pair.substring(0, idx), Charsets.UTF_8.name()) + val value = URLDecoder.decode(pair.substring(idx + 1), Charsets.UTF_8.name()) + key to value + }.getOrNull() + } + .toMap() + + val server = params["server"]?.takeIf { it.isNotBlank() } ?: return null + val token = params["token"]?.takeIf { it.isNotBlank() } ?: return null + + return "invite_claim?server=${server.routeEncode()}&token=${token.routeEncode()}" +} + +private fun String.routeEncode(): String = + URLEncoder.encode(this, Charsets.UTF_8.name()).replace("+", "%20") diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/Routes.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/Routes.kt index c902ccb83..01f549470 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/Routes.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/Routes.kt @@ -35,6 +35,21 @@ sealed class Route(val route: String) { data object Setup : Route("setup") data object Signup : Route("signup") + /** + * Emailed-invitation claim. The deep link carries the server URL and the + * single-use token, so the app skips its "which server?" step entirely. + */ + data class InviteClaim(val server: String, val token: String) : Route( + "invite_claim?server=${Uri.encode(server)}&token=${Uri.encode(token)}", + ) { + companion object { + const val ROUTE = "invite_claim?server={server}&token={token}" + } + } + + /** Server-driven first-run feature tour, shown after profile selection. */ + data object OnboardingTour : Route("onboarding_tour") + data class PairDevice( val token: String? = null, val code: String? = null, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt index 64563f0cc..ff340daef 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt @@ -37,7 +37,7 @@ import androidx.navigation.NavHostController import org.prairieserver.prairie.android.ui.components.MainAppHeaderBodyHeight import org.prairieserver.prairie.android.ui.components.MainAppTopBar import org.prairieserver.prairie.android.ui.navigation.LocalBottomChromeInset -import org.prairieserver.prairie.android.ui.navigation.PrairieBottomNavBar +import org.prairieserver.prairie.android.ui.navigation.SiloBottomNavBar import org.prairieserver.prairie.android.ui.navigation.Route import org.prairieserver.prairie.android.ui.navigation.Tab import org.prairieserver.prairie.android.ui.navigation.fallbackMobileTab @@ -46,18 +46,19 @@ import org.prairieserver.prairie.android.ui.navigation.shouldShowDownloadsTab import org.prairieserver.prairie.android.ui.navigation.visibleMobileTabs import org.prairieserver.prairie.android.ui.screens.calendar.CalendarScreen import org.prairieserver.prairie.android.ui.screens.home.HomeScreen -import org.prairieserver.prairie.android.cast.PrairieCastController -import org.prairieserver.prairie.android.ui.screens.cast.PrairieCastMiniBar -import org.prairieserver.prairie.android.ui.screens.cast.PrairieCastTargetPickerSheet +import org.prairieserver.prairie.android.cast.SiloCastController +import org.prairieserver.prairie.android.ui.screens.cast.SiloCastMiniBar +import org.prairieserver.prairie.android.ui.screens.cast.SiloCastTargetPickerSheet import org.prairieserver.prairie.android.ui.screens.libraries.LibrariesScreen import org.prairieserver.prairie.android.ui.screens.libraries.LibrariesSelectorSheet import org.prairieserver.prairie.android.ui.screens.libraries.LibrariesViewModel import org.prairieserver.prairie.android.ui.screens.recommendations.RecommendationsScreen -import org.prairieserver.prairie.cast.PrairieCastPlaybackRequest +import org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherMenuEntrySheet +import org.prairieserver.prairie.cast.SiloCastPlaybackRequest +import org.prairieserver.prairie.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED import org.prairieserver.prairie.model.navigation.MediaMode import org.prairieserver.prairie.model.navigation.MediaModeCapabilities import org.prairieserver.prairie.model.navigation.mobileMediaModeCapabilities -import org.prairieserver.prairie.model.feature.LiveTvFeatureStore import org.prairieserver.prairie.model.feature.MetadataAiFeatureStore import org.prairieserver.prairie.model.feature.RequestsFeatureStore import org.prairieserver.prairie.common.network.ServerReachabilityMonitor @@ -83,13 +84,14 @@ fun MainScreen( ) { val headerViewModel = koinViewModel() val headerState by headerViewModel.uiState.collectAsState() - val prairieCastController: PrairieCastController = koinInject() - val prairieCastState by prairieCastController.state.collectAsState() - var showPrairieCastTargetPicker by rememberSaveable { mutableStateOf(false) } + val siloCastController: SiloCastController = koinInject() + val siloCastState by siloCastController.state.collectAsState() + var showSiloCastTargetPicker by rememberSaveable { mutableStateOf(false) } + var showWatchTogetherEntry by rememberSaveable { mutableStateOf(false) } fun playVideo(contentId: String, fileId: Int? = null, resumePositionSeconds: Double? = null) { - val launchedRemotely = prairieCastController.launchOnConnectedTarget( - PrairieCastPlaybackRequest( + val launchedRemotely = siloCastController.launchOnConnectedTarget( + SiloCastPlaybackRequest( contentId = contentId, fileId = fileId, startFromBeginning = resumePositionSeconds == null, @@ -97,7 +99,7 @@ fun MainScreen( ), ) if (launchedRemotely) { - navController.navigate(Route.PrairieCastRemote.route) { launchSingleTop = true } + navController.navigate(Route.SiloCastRemote.route) { launchSingleTop = true } } else { navController.navigate( Route.Player( @@ -135,11 +137,9 @@ fun MainScreen( val authRepository: AuthRepository = koinInject() val reachabilityMonitor: ServerReachabilityMonitor = koinInject() val requestsFeatureStore: RequestsFeatureStore = koinInject() - val liveTvFeatureStore: LiveTvFeatureStore = koinInject() val metadataAiFeatureStore: MetadataAiFeatureStore = koinInject() val reachabilityState by reachabilityMonitor.state.collectAsState() val requestsEnabled by requestsFeatureStore.isEnabled.collectAsState() - val liveTvEnabled by liveTvFeatureStore.isEnabled.collectAsState() val reachabilityScope = rememberCoroutineScope() val activeEntry by serverRegistry.activeEntry.collectAsState() val mediaCapabilities by produceState( @@ -204,8 +204,6 @@ fun MainScreen( LaunchedEffect(activeEntry?.id, activeEntry?.profileId, headerState.activeProfile?.id) { requestsFeatureStore.reset() requestsFeatureStore.refresh() - liveTvFeatureStore.reset() - liveTvFeatureStore.refresh() metadataAiFeatureStore.reset() metadataAiFeatureStore.refresh() } @@ -214,7 +212,6 @@ fun MainScreen( reachabilityScope.launch { authRepository.logout() requestsFeatureStore.reset() - liveTvFeatureStore.reset() metadataAiFeatureStore.reset() navController.navigate(Route.Login.route) { popUpTo(0) { inclusive = true } @@ -227,24 +224,25 @@ fun MainScreen( } else { null } - val liveTvMenuAction: (() -> Unit)? = if (liveTvEnabled) { - { navController.navigate(Route.LiveTv.route) } - } else { - null - } + val watchTogetherMenuAction: (() -> Unit)? = + if (CLIENT_WATCH_TOGETHER_SURFACE_ENABLED) { + { showWatchTogetherEntry = true } + } else { + null + } Scaffold( bottomBar = { // The cast bar rests above the nav menu (iOS tabViewBottomAccessory // placement); the Scaffold then pads tab content past both. Column { - PrairieCastMiniBar( - controller = prairieCastController, + SiloCastMiniBar( + controller = siloCastController, onOpenRemote = { - navController.navigate(Route.PrairieCastRemote.route) { launchSingleTop = true } + navController.navigate(Route.SiloCastRemote.route) { launchSingleTop = true } }, ) - PrairieBottomNavBar( + SiloBottomNavBar( currentTab = currentTab, onTabSelected = { tab -> if (tab == Tab.Home && currentTab == Tab.Home) { @@ -293,23 +291,23 @@ fun MainScreen( activeProfile = headerState.activeProfile, onSearchClick = { navController.navigate(Route.Search().route) }, onRemoteControlClick = { - if (prairieCastState.hasActiveSession) { - navController.navigate(Route.PrairieCastRemote.route) + if (siloCastState.hasActiveSession) { + navController.navigate(Route.SiloCastRemote.route) } else { - showPrairieCastTargetPicker = true + showSiloCastTargetPicker = true } }, - onRemoteChooseTvClick = { showPrairieCastTargetPicker = true }, - onRemoteDisconnectClick = { prairieCastController.disconnect() }, - isRemoteControlActive = prairieCastState.hasActiveSession, + onRemoteChooseTvClick = { showSiloCastTargetPicker = true }, + onRemoteDisconnectClick = { siloCastController.disconnect() }, + isRemoteControlActive = siloCastState.hasActiveSession, onRequestsClick = requestsMenuAction, - onLiveTvClick = liveTvMenuAction, + onWatchTogetherClick = watchTogetherMenuAction, onSettingsClick = { navController.navigate(Route.Settings.route) }, onSwitchProfileClick = { navController.navigate(Route.ProfileSelection.route) }, onSwitchServerClick = { - navController.navigate(Route.ServerList.autoScanRoute(autoScan = false)) + navController.navigate(Route.ServerList.route) }, onSignOutClick = ::signOutFromProfileMenu, ) @@ -330,13 +328,13 @@ fun MainScreen( onLibrarySelectorClick = { showLibrarySelector = true }, onSearchClick = { navController.navigate(Route.Search().route) }, onRequestsClick = requestsMenuAction, - onLiveTvClick = liveTvMenuAction, + onWatchTogetherClick = watchTogetherMenuAction, onSettingsClick = { navController.navigate(Route.Settings.route) }, onSwitchProfileClick = { navController.navigate(Route.ProfileSelection.route) }, onSwitchServerClick = { - navController.navigate(Route.ServerList.autoScanRoute(autoScan = false)) + navController.navigate(Route.ServerList.route) }, onSignOutClick = ::signOutFromProfileMenu, ) @@ -406,13 +404,13 @@ fun MainScreen( isProfileLoading = headerState.isLoading, onSearchClick = { navController.navigate(Route.Search().route) }, onRequestsClick = requestsMenuAction, - onLiveTvClick = liveTvMenuAction, + onWatchTogetherClick = watchTogetherMenuAction, onSettingsClick = { navController.navigate(Route.Settings.route) }, onSwitchProfileClick = { navController.navigate(Route.ProfileSelection.route) }, onSwitchServerClick = { - navController.navigate(Route.ServerList.autoScanRoute(autoScan = false)) + navController.navigate(Route.ServerList.route) }, onSignOutClick = ::signOutFromProfileMenu, leadingContent = { @@ -424,7 +422,7 @@ fun MainScreen( color = MaterialTheme.colorScheme.onSurface, ) } else { - org.prairieserver.prairie.android.ui.components.PrairieWordmark() + org.prairieserver.prairie.android.ui.components.SiloWordmark() } }, ) @@ -455,10 +453,21 @@ fun MainScreen( ) } - if (showPrairieCastTargetPicker) { - PrairieCastTargetPickerSheet( - onDismiss = { showPrairieCastTargetPicker = false }, - controller = prairieCastController, + if (showSiloCastTargetPicker) { + SiloCastTargetPickerSheet( + onDismiss = { showSiloCastTargetPicker = false }, + controller = siloCastController, + ) + } + + if (showWatchTogetherEntry) { + WatchTogetherMenuEntrySheet( + onNavigate = { route -> + navController.navigate(route) { + launchSingleTop = true + } + }, + onDismiss = { showWatchTogetherEntry = false }, ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/InviteClaimScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/InviteClaimScreen.kt new file mode 100644 index 000000000..401ffe3ef --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/InviteClaimScreen.kt @@ -0,0 +1,278 @@ +package org.prairieserver.prairie.android.ui.screens.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.android.ui.components.aurora.AuroraErrorLabel +import org.prairieserver.prairie.android.ui.components.aurora.AuroraEyebrow +import org.prairieserver.prairie.android.ui.components.aurora.AuroraGhostButton +import org.prairieserver.prairie.android.ui.components.aurora.AuroraPrimaryButton +import org.prairieserver.prairie.android.ui.components.aurora.AuroraScreen +import org.prairieserver.prairie.android.ui.components.aurora.AuroraScrim +import org.prairieserver.prairie.android.ui.components.aurora.AuroraTextField +import org.prairieserver.prairie.android.ui.components.aurora.AuroraVariant +import org.prairieserver.prairie.android.ui.components.aurora.auroraGlass + +/** + * Emailed-invitation claim: the deep link carried the server and token, so + * this screen asks for a password and nothing else. Everything visual + * mirrors SignupScreen's Aurora treatment. + */ +@Composable +fun InviteClaimScreen( + serverUrl: String, + token: String, + onNavigateToLogin: () -> Unit, + onClaimComplete: () -> Unit, + viewModel: InviteClaimViewModel = koinViewModel(), +) { + val state by viewModel.uiState.collectAsState() + var showPassword by remember { mutableStateOf(false) } + + LaunchedEffect(serverUrl, token) { + viewModel.load(serverUrl, token) + } + + // One-way latch: the success navigation clears the whole back stack, so + // there is nothing to consume or reset. + LaunchedEffect(state.claimSuccess) { + if (state.claimSuccess) onClaimComplete() + } + + state.pendingCleartextOrigin?.let { origin -> + AlertDialog( + onDismissRequest = viewModel::onCancelCleartext, + title = { Text("Use unencrypted HTTP?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(origin, fontWeight = FontWeight.SemiBold) + Text( + "This connection is not encrypted. Anyone on the network may see or change " + + "traffic, including your new password. Continue only on a network you trust.", + ) + } + }, + confirmButton = { + AuroraPrimaryButton( + label = "Use HTTP", + onClick = viewModel::onConfirmCleartext, + ) + }, + dismissButton = { + AuroraGhostButton( + label = "Cancel", + onClick = viewModel::onCancelCleartext, + ) + }, + ) + } + + AuroraScreen(variant = AuroraVariant.SignIn, scrim = AuroraScrim.Soft) { + SiloLogo() + Spacer(Modifier.height(30.dp)) + + when { + state.isLoadingInvitation -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(60.dp)) + CircularProgressIndicator(color = Color(0xFFF3EFE9)) + } + } + + state.lookupFailed -> { + AuroraEyebrow(text = "Invitation", centered = true) + Spacer(Modifier.height(12.dp)) + Text( + text = "Couldn't reach the server", + fontSize = 30.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFFF3EFE9), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + Text( + text = "Your invite is probably still fine — we just couldn't check it. " + + "Make sure you're online and try again.", + fontSize = 15.sp, + color = Color.White.copy(alpha = 0.65f), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + ) + Spacer(Modifier.height(24.dp)) + AuroraPrimaryButton( + label = "Try again", + onClick = viewModel::onRetryLookup, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(10.dp)) + AuroraGhostButton( + label = "Back to sign in", + onClick = onNavigateToLogin, + fillMaxWidth = true, + ) + } + + state.invitationInvalid -> { + AuroraEyebrow(text = "Invitation", centered = true) + Spacer(Modifier.height(12.dp)) + Text( + text = "This invite has expired", + fontSize = 30.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFFF3EFE9), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + Text( + text = "The link may have been used already, revoked, or simply expired. " + + "Ask whoever invited you to send a fresh one.", + fontSize = 15.sp, + color = Color.White.copy(alpha = 0.65f), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + ) + Spacer(Modifier.height(24.dp)) + AuroraGhostButton( + label = "Back to sign in", + onClick = onNavigateToLogin, + fillMaxWidth = true, + ) + } + + else -> { + val invitation = state.invitation ?: return@AuroraScreen + invitation.inviterName?.let { + AuroraEyebrow(text = "Invited by $it", centered = true) + Spacer(Modifier.height(12.dp)) + } + Text( + text = "Welcome to ${invitation.serverName}", + fontSize = 30.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFFF3EFE9), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Choose a password and you're in. You'll sign in with your email address.", + fontSize = 15.sp, + color = Color.White.copy(alpha = 0.65f), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + ) + Spacer(Modifier.height(24.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .auroraGlass(cornerRadius = 24.dp, emphasized = true) + .padding(22.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // The address is fixed by the invitation; show it, don't edit it. + Column { + Text( + text = "EMAIL", + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White.copy(alpha = 0.55f), + ) + Spacer(Modifier.height(8.dp)) + Text( + text = invitation.email, + fontSize = 16.sp, + color = Color.White.copy(alpha = 0.8f), + ) + } + AuroraTextField( + label = "Password", + value = state.password, + onValueChange = viewModel::onPasswordChanged, + placeholder = "••••••", + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next, + visualTransformation = if (showPassword) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailing = { + IconButton(onClick = { showPassword = !showPassword }) { + Icon( + imageVector = if (showPassword) { + Icons.Filled.VisibilityOff + } else { + Icons.Filled.Visibility + }, + contentDescription = if (showPassword) "Hide password" else "Show password", + tint = Color.White.copy(alpha = 0.62f), + ) + } + }, + ) + AuroraTextField( + label = "Confirm password", + value = state.confirmPassword, + onValueChange = viewModel::onConfirmPasswordChanged, + placeholder = "••••••", + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + onImeAction = viewModel::onClaimClick, + visualTransformation = PasswordVisualTransformation(), + ) + + state.error?.let { AuroraErrorLabel(it) } + + AuroraPrimaryButton( + label = if (state.isSubmitting) "Creating…" else "Create account", + onClick = viewModel::onClaimClick, + isLoading = state.isSubmitting, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/InviteClaimViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/InviteClaimViewModel.kt new file mode 100644 index 000000000..af9d3105d --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/InviteClaimViewModel.kt @@ -0,0 +1,200 @@ +package org.prairieserver.prairie.android.ui.screens.auth + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.prairieserver.prairie.common.network.CleartextConsentStore +import org.prairieserver.prairie.model.auth.InvitationLookupResponse +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.errorMessage +import org.prairieserver.prairie.network.requiresApproval +import org.prairieserver.prairie.repository.AuthRepository + +data class InviteClaimUiState( + val isLoadingInvitation: Boolean = true, + val invitation: InvitationLookupResponse? = null, + /** The server said the token itself is dead — the invite really is gone. */ + val invitationInvalid: Boolean = false, + /** The lookup failed for reasons unrelated to the token; offer retry. */ + val lookupFailed: Boolean = false, + /** + * The invite points at a cleartext HTTP server the user hasn't approved. + * The claim POST carries credentials, so it needs the same explicit + * consent the manual server-setup flow collects. + */ + val pendingCleartextOrigin: String? = null, + val password: String = "", + val confirmPassword: String = "", + val isSubmitting: Boolean = false, + val error: String? = null, + val claimSuccess: Boolean = false, +) + +/** + * Claim flow for an emailed invitation deep link (prairie://invite or an + * https app link): server URL and token arrive in the link, the invitee + * chooses only a password. On success the account is created, tokens are + * stored, and the server is registered so the rest of the app works exactly + * as after a normal login. + */ +class InviteClaimViewModel( + private val authRepository: AuthRepository, + private val cleartextConsentStore: CleartextConsentStore, +) : ViewModel() { + + private val _uiState = MutableStateFlow(InviteClaimUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var serverUrl: String = "" + private var token: String = "" + + /** + * Bumped whenever the target invite changes; in-flight lookups compare + * against it before touching state, so a slow response for a superseded + * link can't paint another invite's email over the current one. + */ + private var lookupGeneration = 0 + + fun load(serverUrl: String, token: String) { + // An invite is identified by server *and* token: the same token can + // exist on another server, and matching on the token alone would keep + // the previous server, submitting the password to the wrong one. + if ( + this.serverUrl == serverUrl && + this.token == token && + _uiState.value.invitation != null + ) { + return + } + this.serverUrl = serverUrl + this.token = token + val generation = ++lookupGeneration + _uiState.update { InviteClaimUiState() } + viewModelScope.launch { + val result = authRepository.lookupInvitation(serverUrl, token) + if (generation != lookupGeneration) return@launch + when (result) { + is ApiResult.Success -> _uiState.update { + it.copy(isLoadingInvitation = false, invitation = result.data) + } + is ApiResult.Error -> { + // Only statuses that speak about the token itself are + // terminal. A 429/5xx says nothing about the invite, and + // showing "expired" for it sends the user off to have a + // valid link revoked and reissued. + if (result.code in TERMINAL_LOOKUP_CODES) { + _uiState.update { + it.copy(isLoadingInvitation = false, invitationInvalid = true) + } + } else { + _uiState.update { + it.copy(isLoadingInvitation = false, lookupFailed = true) + } + } + } + // A failure to reach the server says nothing about the invite + // either. + is ApiResult.NetworkError -> _uiState.update { + it.copy(isLoadingInvitation = false, lookupFailed = true) + } + } + } + } + + fun onRetryLookup() { + val url = serverUrl + val tok = token + // Clear the loaded marker so load() runs the lookup again. + this.token = "" + load(url, tok) + } + + fun onPasswordChanged(value: String) { + _uiState.update { it.copy(password = value, error = null) } + } + + fun onConfirmPasswordChanged(value: String) { + _uiState.update { it.copy(confirmPassword = value, error = null) } + } + + fun onClaimClick() { + val current = _uiState.value + val validationError = when { + current.password.length < 8 -> "Password must be at least 8 characters" + current.password != current.confirmPassword -> "Passwords do not match" + else -> null + } + if (validationError != null) { + _uiState.update { it.copy(error = validationError) } + return + } + + viewModelScope.launch { + // The read-only lookup may have slipped past the cleartext gate, + // but the claim POST carries a password and will be rejected by + // the interceptor for an unapproved http:// origin — surfacing as + // an opaque network error. Ask for the same consent server setup + // does, then proceed. + if (cleartextConsentStore.requiresApproval(serverUrl)) { + _uiState.update { it.copy(pendingCleartextOrigin = serverUrl) } + return@launch + } + submitClaim() + } + } + + fun onConfirmCleartext() { + val origin = _uiState.value.pendingCleartextOrigin ?: return + viewModelScope.launch { + cleartextConsentStore.approve(origin) + _uiState.update { it.copy(pendingCleartextOrigin = null) } + submitClaim() + } + } + + fun onCancelCleartext() { + _uiState.update { it.copy(pendingCleartextOrigin = null) } + } + + private suspend fun submitClaim() { + val current = _uiState.value + // Pin the invite this submission is for. A second deep link can replace + // the route mid-POST; without this the first response would still drive + // the UI — navigating away from the invite now on screen, or reporting + // success for an account on a server the user is no longer claiming. + val generation = lookupGeneration + val claimServerUrl = serverUrl + val claimToken = token + _uiState.update { it.copy(isSubmitting = true, error = null) } + // acceptInvitation talks to the invite's server directly and only + // adopts it as the active server after the claim succeeds, so a + // failed claim leaves any existing session untouched. + val result = authRepository.acceptInvitation(claimServerUrl, claimToken, current.password) + if (generation != lookupGeneration) return + when (result) { + is ApiResult.Success -> { + _uiState.update { it.copy(isSubmitting = false, claimSuccess = true) } + } + is ApiResult.Error -> { + val message = when (result.code) { + 404 -> "This invitation is invalid or has expired." + 409 -> "This invitation has already been used." + else -> result.errorMessage("Could not create your account") + } + _uiState.update { it.copy(isSubmitting = false, error = message) } + } + is ApiResult.NetworkError -> _uiState.update { + it.copy(isSubmitting = false, error = result.errorMessage("Could not create your account")) + } + } + } + + private companion object { + /** Statuses that mean the token itself is gone, used, or malformed. */ + private val TERMINAL_LOOKUP_CODES = setOf(400, 404, 409, 410) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/CatalogGrid.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/CatalogGrid.kt index 4f058964c..6d945460c 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/CatalogGrid.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/CatalogGrid.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.prairieserver.prairie.android.ui.components.MediaCard @@ -65,6 +66,7 @@ fun CatalogGrid( selectedNamePrefix: String? = null, onNamePrefixSelected: ((String?) -> Unit)? = null, viewDensity: CatalogViewDensity = CatalogViewDensity.Normal, + bottomContentInset: Dp = 0.dp, ) { val gridState = rememberLazyGridState() val cardWidth = viewDensity.minCardWidth @@ -94,7 +96,7 @@ fun CatalogGrid( start = 16.dp, top = 8.dp, end = if (onNamePrefixSelected != null) 56.dp else 16.dp, - bottom = 8.dp, + bottom = 8.dp + bottomContentInset, ), horizontalArrangement = Arrangement.spacedBy(MediaGridDefaults.PosterGridHorizontalSpacing), verticalArrangement = Arrangement.spacedBy(MediaGridDefaults.PosterGridVerticalSpacing), @@ -142,7 +144,8 @@ fun CatalogGrid( onNamePrefixSelected = onSelected, modifier = Modifier .align(Alignment.CenterEnd) - .padding(end = 6.dp), + .padding(end = 6.dp) + .padding(bottom = bottomContentInset), ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt index b799366e2..3872026f8 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt @@ -31,7 +31,6 @@ import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.Layers import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Replay import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.StarBorder import androidx.compose.material.icons.outlined.KeyboardArrowDown @@ -61,21 +60,22 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.prairieserver.prairie.android.ui.theme.PrairieBackground -import org.prairieserver.prairie.android.ui.theme.PrairieOnSurface -import org.prairieserver.prairie.android.ui.theme.PrairieSecondaryText -import org.prairieserver.prairie.android.ui.theme.PrairieSurfaceElevated +import org.prairieserver.prairie.android.ui.theme.SiloBackground +import org.prairieserver.prairie.android.ui.theme.SiloOnSurface +import org.prairieserver.prairie.android.ui.theme.SiloSecondaryText +import org.prairieserver.prairie.android.ui.theme.SiloSurfaceElevated import org.prairieserver.prairie.android.ui.navigation.heroTarget import org.prairieserver.prairie.android.ui.theme.PillShape import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.model.catalog.Season +import org.prairieserver.prairie.model.catalog.isSpecialsForDisplay // ── Tokens ──────────────────────────────────────────────────── -internal val DetailPrimaryText = PrairieOnSurface -internal val DetailSecondaryText = PrairieOnSurface.copy(alpha = 0.78f) -internal val DetailTertiaryText = PrairieOnSurface.copy(alpha = 0.55f) +internal val DetailPrimaryText = SiloOnSurface +internal val DetailSecondaryText = SiloOnSurface.copy(alpha = 0.78f) +internal val DetailTertiaryText = SiloOnSurface.copy(alpha = 0.55f) internal val SafePadding = 16.dp internal val SmallPadding = 8.dp @@ -106,7 +106,8 @@ fun DetailHero( sourceTokens: List, factsLine: List, modifier: Modifier = Modifier, - dominantColor: Color = PrairieBackground, + dominantColor: Color = SiloBackground, + directorText: String? = null, // Optional viewer-facing description-translation affordance, rendered // directly under the overview (Apple parity: DescriptionTranslationView). translation: (@Composable () -> Unit)? = null, @@ -144,6 +145,18 @@ fun DetailHero( OverviewBlock(text = overview) } translation?.invoke() + directorText?.takeIf { it.isNotBlank() }?.let { line -> + Text( + text = line, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = Color.White.copy(alpha = 0.62f), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } if (factsLine.isNotEmpty()) { FactsRow(tokens = factsLine) } @@ -190,8 +203,8 @@ private fun Backdrop( Brush.verticalGradient( 0.00f to Color.Transparent, 0.55f to Color.Transparent, - 0.85f to PrairieBackground.copy(alpha = 0.6f), - 1.00f to PrairieBackground, + 0.85f to SiloBackground.copy(alpha = 0.6f), + 1.00f to SiloBackground, ), ), ) @@ -323,7 +336,7 @@ private fun splitHeroTitle(raw: String): Pair { private fun EyebrowChip(text: String) { Surface( shape = PillShape, - color = PrairieSurfaceElevated, + color = SiloSurfaceElevated, ) { Text( text = text, @@ -721,29 +734,13 @@ fun HeroActionStack( TextButton(onClick = { showResumeDialog = false onPlay() - }) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Resume") - } + }) { Text("Resume") } }, dismissButton = { TextButton(onClick = { showResumeDialog = false onPlayFromBeginning() - }) { - Icon( - imageVector = Icons.Default.Replay, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Play from Beginning") - } + }) { Text("Play from Beginning") } }, ) } @@ -778,7 +775,7 @@ fun SectionHeader( lineHeight = 15.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.6.sp, - color = PrairieOnSurface.copy(alpha = 0.7f), + color = SiloOnSurface.copy(alpha = 0.7f), maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -798,7 +795,7 @@ fun SectionHeader( text = trailingText, fontSize = 13.sp, fontWeight = FontWeight.Medium, - color = PrairieSecondaryText, + color = SiloSecondaryText, ) } } @@ -826,7 +823,7 @@ fun SeasonChips( contentType = { "season-chip" }, ) { season -> val isSelected = season.seasonNumber == selectedSeasonNumber - val label = if (season.isSpecials) "Specials" else "Season ${season.seasonNumber}" + val label = phoneSeasonLabel(season) // iOS PhoneSeasonChips: 14pt (semibold selected / medium // unselected), hpad 16, height 36, unselected fill white-0.06. Surface( @@ -871,8 +868,8 @@ object HeroMetadata { val s = detail.seasonNumber val e = detail.episodeNumber return when { - s != null && e != null -> "Season $s · Episode $e" - s != null -> "Season $s" + s != null && e != null -> "${phoneSeasonNumberLabel(s)} · Episode $e" + s != null -> phoneSeasonNumberLabel(s) else -> null } } @@ -917,6 +914,12 @@ object HeroMetadata { } } +internal fun phoneSeasonLabel(season: Season): String = + if (season.isSpecialsForDisplay()) "Specials" else phoneSeasonNumberLabel(season.seasonNumber) + +private fun phoneSeasonNumberLabel(seasonNumber: Int): String = + if (seasonNumber == 0) "Specials" else "Season $seasonNumber" + // ── Play label helper ───────────────────────────────────────── // iOS parity: the play-button label stays neutral ("Play" / "Play S·E") even @@ -992,7 +995,7 @@ fun DetailFactsList( lineHeight = 15.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.2.sp, - color = PrairieOnSurface.copy(alpha = 0.7f), + color = SiloOnSurface.copy(alpha = 0.7f), modifier = Modifier.width(100.dp), ) Text( diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt index 06b41abf8..a0411410c 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt @@ -648,7 +648,11 @@ fun ItemDetailScreen( ?: playbackResumePosition(detail.userData), ) }, - onSuggestToRoom = if (suggestRoom != null && nextEpisode != null) { + onSuggestToRoom = if ( + CLIENT_WATCH_TOGETHER_SURFACE_ENABLED && + suggestRoom != null && + nextEpisode != null + ) { { suggestViewModel.suggest( contentId = nextEpisode.contentId, @@ -846,7 +850,9 @@ fun ItemDetailScreen( resumePositionSeconds = playbackResumePosition(detail.userData), ) }, - onSuggestToRoom = suggestRoom?.let { + onSuggestToRoom = if ( + CLIENT_WATCH_TOGETHER_SURFACE_ENABLED && suggestRoom != null + ) { { suggestViewModel.suggest( contentId = detail.contentId, @@ -856,6 +862,8 @@ fun ItemDetailScreen( posterUrl = detail.posterUrl, ) } + } else { + null }, onWatchTogether = if (CLIENT_WATCH_TOGETHER_SURFACE_ENABLED) { { onWatchTogether(detail.contentId, explicitFileId) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt index 31d3bd328..7368d83f2 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt @@ -9,6 +9,7 @@ import org.prairieserver.prairie.model.catalog.FileVersion import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.model.catalog.LeafItemUserData import org.prairieserver.prairie.model.catalog.Season +import org.prairieserver.prairie.model.catalog.initialSeasonDisplayPlan import org.prairieserver.prairie.model.catalog.sortedForDisplay import org.prairieserver.prairie.model.download.DownloadCapability import org.prairieserver.prairie.model.download.DownloadRecord @@ -464,23 +465,21 @@ class ItemDetailViewModel( viewModelScope.launch { when (val result = catalogRepository.getSeasons(seriesId)) { is ApiResult.Success -> { - val seasons = result.data.seasons.sortedForDisplay() - val selectedSeason = seasons.firstOrNull { it.seasonNumber == initialSeasonNumber } - ?: seasons.firstOrNull() + val plan = result.data.seasons.initialSeasonDisplayPlan(initialSeasonNumber) _uiState.update { it.copy( - seasons = seasons, - selectedSeasonNumber = selectedSeason?.seasonNumber ?: 1, + seasons = plan.seasons, + selectedSeasonNumber = plan.selectedSeasonNumber ?: 1, ) } - if (selectedSeason != null) { + plan.episodeRequestSeasonNumber?.let { seasonNumber -> loadEpisodes( seriesId = seriesId, - seasonNumber = selectedSeason.seasonNumber, - seasonsForDownloadRollup = seasons, + seasonNumber = seasonNumber, + seasonsForDownloadRollup = plan.seasons, ) - } else { - loadAllEpisodeFileIds(seriesId, seasons) + } ?: run { + loadAllEpisodeFileIds(seriesId, plan.seasons) } } else -> { /* Season load failure is non-critical */ } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt index a655c7192..c709e4eed 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt @@ -40,8 +40,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import org.prairieserver.prairie.android.ui.theme.PrairieBackground +import org.prairieserver.prairie.android.ui.theme.SiloBackground import org.prairieserver.prairie.android.ui.util.rememberDominantColor +import org.prairieserver.prairie.common.ui.movieDirectorCredit import org.prairieserver.prairie.model.catalog.EpisodeListItem import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.model.catalog.Season @@ -105,7 +106,7 @@ fun MovieDetailContent( var showSubtitlePicker by remember { mutableStateOf(false) } var showRatingSheet by remember { mutableStateOf(false) } - val dominantColor by rememberDominantColor(detail.backdropUrl, fallback = PrairieBackground) + val dominantColor by rememberDominantColor(detail.backdropUrl, fallback = SiloBackground) val selectedVersion = detail.versions.getOrNull(selectedVersionIndex) val audioTracks = selectedVersion?.audioTracks.orEmpty() @@ -128,7 +129,7 @@ fun MovieDetailContent( LazyColumn( modifier = modifier .fillMaxSize() - .background(PrairieBackground) + .background(SiloBackground) .background(detailScreenBackgroundBrush(dominantColor)), verticalArrangement = Arrangement.spacedBy(36.dp), ) { @@ -139,6 +140,7 @@ fun MovieDetailContent( sourceTokens = sourceTokens, factsLine = factsLine, dominantColor = dominantColor, + directorText = movieDirectorCredit(detail), translation = translation, ) { HeroActionStack( diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeriesDetailContent.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeriesDetailContent.kt index 2e95e9d1a..a4c7a4a3e 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeriesDetailContent.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeriesDetailContent.kt @@ -187,7 +187,7 @@ fun SeriesDetailContent( verticalAlignment = Alignment.Bottom, ) { SectionHeader( - label = selectedSeason?.let { "Season ${it.seasonNumber}" } ?: "Episodes", + label = seriesSeasonSectionLabel(selectedSeason), title = "Episodes", trailingText = episodeCountSubtitle, modifier = Modifier.weight(1f), @@ -212,7 +212,10 @@ fun SeriesDetailContent( when { allDownloaded -> Icon( imageVector = Icons.Filled.DownloadDone, - contentDescription = "Season $seasonNumberForDownload downloaded", + contentDescription = seasonDownloadContentDescription( + selectedSeason, + isDownloaded = true, + ), tint = DetailPrimaryText, modifier = Modifier.size(24.dp), ) @@ -223,7 +226,10 @@ fun SeriesDetailContent( ) else -> Icon( imageVector = Icons.Outlined.FileDownload, - contentDescription = "Download season $seasonNumberForDownload", + contentDescription = seasonDownloadContentDescription( + selectedSeason, + isDownloaded = false, + ), tint = DetailPrimaryText, modifier = Modifier.size(24.dp), ) @@ -315,3 +321,18 @@ fun SeriesDetailContent( ) } } + +internal fun seriesSeasonSectionLabel(season: Season?): String = + season?.let(::phoneSeasonLabel) ?: "Episodes" + +internal fun seasonDownloadContentDescription( + season: Season, + isDownloaded: Boolean, +): String { + val label = phoneSeasonLabel(season) + return if (isDownloaded) { + "$label downloaded" + } else { + "Download ${label.replaceFirstChar(Char::lowercase)}" + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedCarousel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedCarousel.kt index 2210f5ce3..4a60e6547 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedCarousel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedCarousel.kt @@ -9,17 +9,16 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.pager.HorizontalPager @@ -84,7 +83,7 @@ fun FeaturedCarousel( onInfoClick: (String) -> Unit, modifier: Modifier = Modifier, onActiveBackdropChange: ((url: String?, thumbhash: String?) -> Unit)? = null, - extraTopInset: androidx.compose.ui.unit.Dp = 0.dp, + topInset: androidx.compose.ui.unit.Dp = 16.dp, ) { if (items.isEmpty()) return @@ -99,12 +98,6 @@ fun FeaturedCarousel( val sideInset = ((screenWidthDp - cardWidth.value) / 2f).coerceAtLeast(16f).dp val cardCornerRadius = 28.dp - val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - // Push the deck below the floating chrome (status bar + ~52dp chrome + breathing room). - // Callers with taller chrome (e.g. Libraries with header + tab row) pass - // additional inset via [extraTopInset]. - val deckTopInset = statusBarTop + 64.dp + extraTopInset - LaunchedEffect(pagerState, items.size) { if (items.size > 1) { while (true) { @@ -130,7 +123,7 @@ fun FeaturedCarousel( modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - Spacer(modifier = Modifier.height(deckTopInset)) + Spacer(modifier = Modifier.height(topInset)) HorizontalPager( state = pagerState, @@ -279,6 +272,7 @@ private fun FeaturedCard( } @Composable +@OptIn(ExperimentalLayoutApi::class) private fun FeaturedCardContent( item: SectionItem, visibility: Float, @@ -336,9 +330,14 @@ private fun FeaturedCardContent( ) } - val chips = remember(item) { metadataChips(item) } + val chips = remember(item) { featuredHeroMetadata(item) } if (chips.isNotEmpty()) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + maxLines = 2, + ) { chips.forEach { chip -> MetadataChip(chip) } } } @@ -352,7 +351,7 @@ private fun FeaturedCardContent( } @Composable -private fun MetadataChip(chip: HeroChip) { +private fun MetadataChip(chip: FeaturedHeroMetadataChip) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp), @@ -366,16 +365,16 @@ private fun MetadataChip(chip: HeroChip) { ) .padding(horizontal = 12.dp, vertical = 6.dp), ) { - if (chip.icon != null) { + if (chip.kind == FeaturedHeroMetadataKind.Rating) { Icon( - imageVector = chip.icon, + imageVector = Icons.Default.Star, contentDescription = null, - tint = chip.iconTint ?: Color.White.copy(alpha = 0.94f), + tint = Color(0xFFFFCA28), modifier = Modifier.size(12.dp), ) } Text( - text = chip.title, + text = chip.label, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = Color.White.copy(alpha = 0.94f), @@ -464,41 +463,8 @@ private fun FeaturedActionRow( } } -private data class HeroChip( - val title: String, - val icon: androidx.compose.ui.graphics.vector.ImageVector? = null, - val iconTint: Color? = null, -) - -private fun metadataChips(item: SectionItem): List { - val chips = mutableListOf() - chips += HeroChip(title = item.type.replaceFirstChar { it.uppercase() }) - episodeToken(item)?.let { chips += HeroChip(title = it) } - item.ratingImdb?.let { rating -> - chips += HeroChip( - title = "%.1f".format(rating), - icon = Icons.Default.Star, - iconTint = Color(0xFFFFCA28), - ) - } - if (item.year > 0) chips += HeroChip(title = item.year.toString()) - return chips -} - private fun eyebrowFor(item: SectionItem): String? { if (!item.type.equals("episode", ignoreCase = true)) return null val seriesTitle = item.seriesTitle return if (!seriesTitle.isNullOrBlank()) seriesTitle else null } - -private fun episodeToken(item: SectionItem): String? { - if (!item.type.equals("episode", ignoreCase = true)) return null - val season = item.seasonNumber - val episode = item.episodeNumber - return when { - season != null && episode != null -> "S$season E$episode" - season != null -> "Season $season" - episode != null -> "Episode $episode" - else -> null - } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadata.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadata.kt new file mode 100644 index 000000000..4e993bb43 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadata.kt @@ -0,0 +1,83 @@ +package org.prairieserver.prairie.android.ui.screens.home + +import java.util.Locale +import kotlin.math.roundToInt +import org.prairieserver.prairie.model.section.SectionItem + +internal enum class FeaturedHeroMetadataKind { + Plain, + Rating, + Classification, +} + +internal data class FeaturedHeroMetadataChip( + val label: String, + val kind: FeaturedHeroMetadataKind = FeaturedHeroMetadataKind.Plain, +) + +internal fun featuredHeroMetadata(item: SectionItem): List { + val result = mutableListOf() + val isEpisode = item.type.equals("episode", ignoreCase = true) + + if (isEpisode) { + episodeToken(item.seasonNumber, item.episodeNumber)?.let { + result += FeaturedHeroMetadataChip(it) + } + } else if (item.year > 0) { + result += FeaturedHeroMetadataChip(item.year.toString()) + } + + formatFeaturedRuntime(item.runtime, item.durationSeconds)?.let { + result += FeaturedHeroMetadataChip(it) + } + validImdbRating(item.ratingImdb) + ?.let { + result += FeaturedHeroMetadataChip( + label = String.format(Locale.US, "%.1f", it), + kind = FeaturedHeroMetadataKind.Rating, + ) + } + if (!isEpisode) { + item.genres.firstOrNull { it.isNotBlank() }?.let { + result += FeaturedHeroMetadataChip(it) + } + } + item.contentRating + ?.takeIf { it.isNotBlank() } + ?.uppercase(Locale.US) + ?.let { + result += FeaturedHeroMetadataChip( + label = it, + kind = FeaturedHeroMetadataKind.Classification, + ) + } + return result +} + +private fun validImdbRating(rating: Double?): Double? = + rating?.takeIf { it.isFinite() && it > 0.0 && it <= 10.0 } + +private fun episodeToken(season: Int?, episode: Int?): String? = when { + season != null && episode != null -> "S$season E$episode" + season != null -> "Season $season" + episode != null -> "Episode $episode" + else -> null +} + +/** Episode/movie length: the metadata runtime when present, else derived + * from the file duration the payload already carries. */ +private fun formatFeaturedRuntime(runtimeMinutes: Int?, durationSeconds: Double?): String? { + runtimeMinutes?.takeIf { it > 0 }?.let { return formatRuntimeMinutes(it) } + val duration = durationSeconds?.takeIf { it.isFinite() && it > 0.0 } + ?: return null + val minutes = (duration / 60.0).roundToInt().takeIf { it > 0 } + ?: return null + return formatRuntimeMinutes(minutes) +} + +private fun formatRuntimeMinutes(minutes: Int): String { + if (minutes < 60) return "$minutes min" + val hours = minutes / 60 + val remainder = minutes % 60 + return if (remainder == 0) "${hours}h" else "${hours}h ${remainder}m" +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt index ab1117980..6de6da704 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt @@ -49,7 +49,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import org.prairieserver.prairie.android.ui.components.PrairieWordmark +import org.prairieserver.prairie.android.ui.components.SiloWordmark import org.prairieserver.prairie.android.ui.components.EmptyStateView import org.prairieserver.prairie.android.ui.components.ErrorView import org.prairieserver.prairie.android.ui.components.MediaRowSkeleton @@ -93,7 +93,7 @@ fun HomeScreen( onRemoteDisconnectClick: () -> Unit, isRemoteControlActive: Boolean, onRequestsClick: (() -> Unit)?, - onLiveTvClick: (() -> Unit)?, + onWatchTogetherClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, @@ -176,7 +176,7 @@ fun HomeScreen( LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - // iOS `sectionSpacing` = PrairieTheme.largePadding (24). + // iOS `sectionSpacing` = SiloTheme.largePadding (24). verticalArrangement = Arrangement.spacedBy(24.dp), ) { // Reserve runway under the floating header so the first row @@ -223,7 +223,7 @@ fun HomeScreen( ) } - // iOS bottom padding = PrairieTheme.largePadding (24), plus the + // iOS bottom padding = SiloTheme.largePadding (24), plus the // translucent bottom chrome the content scrolls beneath. item(key = "bottomPad") { Spacer(modifier = Modifier.height(24.dp + LocalBottomChromeInset.current)) @@ -242,7 +242,7 @@ fun HomeScreen( onRemoteDisconnectClick = onRemoteDisconnectClick, isRemoteControlActive = isRemoteControlActive, onRequestsClick = onRequestsClick, - onLiveTvClick = onLiveTvClick, + onWatchTogetherClick = onWatchTogetherClick, onSettingsClick = onSettingsClick, onSwitchProfileClick = onSwitchProfileClick, onSwitchServerClick = onSwitchServerClick, @@ -300,7 +300,7 @@ private fun HomeFloatingChrome( onRemoteDisconnectClick: () -> Unit, isRemoteControlActive: Boolean, onRequestsClick: (() -> Unit)?, - onLiveTvClick: (() -> Unit)?, + onWatchTogetherClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, @@ -309,8 +309,8 @@ private fun HomeFloatingChrome( val statusBarPadding = WindowInsets.statusBars.asPaddingValues() // iOS chrome: translucent glass fill plus a bottom hairline that strengthens // as it fades in (white 0.06 → 0.10, 0.75pt). headerTopReclaim(16) pulls the - // row up beside the status-bar glyphs; horizontal = PrairieTheme.padding(16), - // bottom = PrairieTheme.smallPadding(8). + // row up beside the status-bar glyphs; horizontal = SiloTheme.padding(16), + // bottom = SiloTheme.smallPadding(8). val hairlineAlpha = 0.06f + 0.04f * scrollProgress Box( modifier = Modifier @@ -325,8 +325,8 @@ private fun HomeFloatingChrome( .padding(start = 16.dp, end = 16.dp, bottom = 8.dp) .fillMaxWidth(), ) { - // Leading: Prairie wordmark (iOS PrairieWordmarkView width: 72). - PrairieWordmark( + // Leading: Prairie wordmark (iOS SiloWordmarkView width: 72). + SiloWordmark( modifier = Modifier .align(Alignment.CenterStart), width = 72.dp, @@ -338,7 +338,7 @@ private fun HomeFloatingChrome( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { - // Mirrors Apple's PrairieControlModeButton: chrome-free at rest, + // Mirrors Apple's SiloControlModeButton: chrome-free at rest, // filled disc while controlling a TV; the active state opens a // menu instead of jumping straight to the remote. Box { @@ -402,7 +402,7 @@ private fun HomeFloatingChrome( HomeProfileMenu( activeProfile = activeProfile, onRequestsClick = onRequestsClick, - onLiveTvClick = onLiveTvClick, + onWatchTogetherClick = onWatchTogetherClick, onSettingsClick = onSettingsClick, onSwitchProfileClick = onSwitchProfileClick, onSwitchServerClick = onSwitchServerClick, @@ -449,7 +449,7 @@ private fun HomeChromeButton( private fun HomeProfileMenu( activeProfile: Profile?, onRequestsClick: (() -> Unit)?, - onLiveTvClick: (() -> Unit)?, + onWatchTogetherClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, @@ -494,18 +494,16 @@ private fun HomeProfileMenu( }, ) } - if (onLiveTvClick != null) { + if (onWatchTogetherClick != null) { DropdownMenuItem( - text = { Text("Live TV") }, + text = { Text("Watch Together") }, onClick = { menuExpanded = false - onLiveTvClick() + onWatchTogetherClick() }, ) } - if (onRequestsClick != null || onLiveTvClick != null) { - HorizontalDivider() - } + HorizontalDivider() DropdownMenuItem( text = { Text("Settings") }, onClick = { diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt index 9fc0d5bd5..b851c045e 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt @@ -66,6 +66,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -73,7 +74,6 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.ViewModel @@ -107,7 +107,7 @@ import org.prairieserver.prairie.model.catalog.isAudiobookItemType import org.prairieserver.prairie.android.ui.screens.home.FeaturedCarousel import org.prairieserver.prairie.android.ui.screens.home.HomeSectionRow import org.prairieserver.prairie.android.ui.screens.profiles.ProfileAvatar -import org.prairieserver.prairie.android.ui.theme.PrairieSurfaceElevated +import org.prairieserver.prairie.android.ui.theme.SiloSurfaceElevated import org.prairieserver.prairie.android.ui.util.formatCardDate import org.prairieserver.prairie.android.ui.util.rememberDominantColor import org.prairieserver.prairie.common.ui.components.ThumbhashImage @@ -194,6 +194,10 @@ class LibrariesViewModel( private var recommendedLoadedLibraryId: Int? = null private var browseLoadedLibraryId: Int? = null private var collectionsLoadedLibraryId: Int? = null + private var recommendedRequestGeneration = 0L + private var catalogRequestGeneration = 0L + private var catalogQueryGeneration = 0L + private var collectionsRequestGeneration = 0L private val pageSize = 42 init { @@ -400,41 +404,62 @@ class LibrariesViewModel( private fun loadRecommended(libraryId: Int, force: Boolean) { if (!force && recommendedLoadedLibraryId == libraryId) return recommendedLoadedLibraryId = libraryId + val requestGeneration = ++recommendedRequestGeneration viewModelScope.launch { + if (!isRecommendedRequestCurrent(requestGeneration, libraryId)) return@launch _uiState.update { - it.copy( - isLoadingSections = true, - sectionsError = null, - sections = emptyList(), - ) + if (isRecommendedRequestCurrent(requestGeneration, libraryId, it)) { + it.copy( + isLoadingSections = true, + sectionsError = null, + sections = emptyList(), + ) + } else { + it + } } when (val result = sectionRepository.getLibrarySections(libraryId)) { is ApiResult.Success -> { + if (!isRecommendedRequestCurrent(requestGeneration, libraryId)) return@launch _uiState.update { - it.copy( - isLoadingSections = false, - sections = result.data.sections.filter { section -> section.items.isNotEmpty() }, - sectionsError = null, - ) + if (isRecommendedRequestCurrent(requestGeneration, libraryId, it)) { + it.copy( + isLoadingSections = false, + sections = result.data.sections.filter { section -> section.items.isNotEmpty() }, + sectionsError = null, + ) + } else { + it + } } } is ApiResult.Error -> { + if (!isRecommendedRequestCurrent(requestGeneration, libraryId)) return@launch _uiState.update { - it.copy( - isLoadingSections = false, - sections = emptyList(), - sectionsError = result.message.ifBlank { "Failed to load recommendations" }, - ) + if (isRecommendedRequestCurrent(requestGeneration, libraryId, it)) { + it.copy( + isLoadingSections = false, + sections = emptyList(), + sectionsError = result.message.ifBlank { "Failed to load recommendations" }, + ) + } else { + it + } } } is ApiResult.NetworkError -> { + if (!isRecommendedRequestCurrent(requestGeneration, libraryId)) return@launch _uiState.update { - it.copy( - isLoadingSections = false, - sections = emptyList(), - sectionsError = "Network error: ${result.exception.message ?: "unknown"}", - ) + if (isRecommendedRequestCurrent(requestGeneration, libraryId, it)) { + it.copy( + isLoadingSections = false, + sections = emptyList(), + sectionsError = "Network error: ${result.exception.message ?: "unknown"}", + ) + } else { + it + } } } } @@ -446,18 +471,37 @@ class LibrariesViewModel( return } browseLoadedLibraryId = libraryId + val requestState = _uiState.value + val requestIdentity = CatalogRequestIdentity( + libraryId = libraryId, + browseSort = requestState.browseSort, + selectedNamePrefix = requestState.selectedNamePrefix, + filterState = requestState.filterState, + ) + val requestGeneration = ++catalogRequestGeneration + val queryGeneration = + if (reset) ++catalogQueryGeneration else catalogQueryGeneration + val offset = if (reset) 0 else requestState.catalogItems.size viewModelScope.launch { - val state = _uiState.value - val offset = if (reset) 0 else state.catalogItems.size + if (!isCatalogRequestCurrent(requestGeneration, requestIdentity)) return@launch - if (reset && state.availableFilters == null) { + if (reset && requestState.availableFilters == null) { launch { // includeTechnical: resolution + audio/subtitle-language facets // are only fetched on request (iOS parity). Keep the FULL // response so the filter sheet has every facet, not just genres. when (val filters = catalogRepository.getFilters(libraryId, includeTechnical = true)) { is ApiResult.Success -> { - _uiState.update { it.copy(availableFilters = filters.data) } + if (!isCatalogQueryCurrent(queryGeneration, requestIdentity)) { + return@launch + } + _uiState.update { + if (isCatalogQueryCurrent(queryGeneration, requestIdentity, it)) { + it.copy(availableFilters = filters.data) + } else { + it + } + } } else -> Unit } @@ -465,38 +509,34 @@ class LibrariesViewModel( } _uiState.update { - if (reset) { - it.copy( - isLoadingCatalog = true, - isLoadingMoreCatalog = false, - catalogError = null, - ) + if (!isCatalogRequestCurrent(requestGeneration, requestIdentity, it)) { + it + } else if (reset) { + it.copy(isLoadingCatalog = true, isLoadingMoreCatalog = false, catalogError = null) } else { - it.copy( - isLoadingMoreCatalog = true, - catalogError = null, - ) + it.copy(isLoadingMoreCatalog = true, catalogError = null) } } when ( val result = catalogRepository.browse( libraryId = libraryId, - sort = state.browseSort.sortField, - order = state.browseSort.sortOrder, + sort = requestState.browseSort.sortField, + order = requestState.browseSort.sortOrder, offset = offset, limit = pageSize, - namePrefix = state.selectedNamePrefix, + namePrefix = requestState.selectedNamePrefix, // Full facet filtering (genre/decade/rating/studio/language/...) // via the shared query builder — replaces the single-genre param. - queryGroups = CatalogFilterQueryBuilder.buildGroups(state.filterState), - match = CatalogFilterQueryBuilder.matchParam(state.filterState) - .takeIf { state.filterState.hasActiveFilters }, + queryGroups = CatalogFilterQueryBuilder.buildGroups(requestState.filterState), + match = CatalogFilterQueryBuilder.matchParam(requestState.filterState) + .takeIf { requestState.filterState.hasActiveFilters }, ) ) { is ApiResult.Success -> { // Overlay local optimistic watched/favorite (mirrors Home/Browse). val overlaid = overlayLocalState(result.data.items) + if (!isCatalogRequestCurrent(requestGeneration, requestIdentity)) return@launch // Audiobook libraries expose book-native facets // (author/narrator/series) — detected from the first item. val detectedMediaType = overlaid.firstOrNull()?.let { first -> @@ -507,33 +547,47 @@ class LibrariesViewModel( } } _uiState.update { - it.copy( - isLoadingCatalog = false, - isLoadingMoreCatalog = false, - catalogItems = if (reset) overlaid else it.catalogItems + overlaid, - catalogTotal = result.data.total, - catalogHasMore = result.data.hasMore, - browseMediaType = detectedMediaType ?: it.browseMediaType, - catalogError = null, - ) + if (isCatalogRequestCurrent(requestGeneration, requestIdentity, it)) { + it.copy( + isLoadingCatalog = false, + isLoadingMoreCatalog = false, + catalogItems = if (reset) overlaid else it.catalogItems + overlaid, + catalogTotal = result.data.total, + catalogHasMore = result.data.hasMore, + browseMediaType = detectedMediaType ?: it.browseMediaType, + catalogError = null, + ) + } else { + it + } } } is ApiResult.Error -> { + if (!isCatalogRequestCurrent(requestGeneration, requestIdentity)) return@launch _uiState.update { - it.copy( - isLoadingCatalog = false, - isLoadingMoreCatalog = false, - catalogError = result.message.ifBlank { "Failed to load catalog" }, - ) + if (isCatalogRequestCurrent(requestGeneration, requestIdentity, it)) { + it.copy( + isLoadingCatalog = false, + isLoadingMoreCatalog = false, + catalogError = result.message.ifBlank { "Failed to load catalog" }, + ) + } else { + it + } } } is ApiResult.NetworkError -> { + if (!isCatalogRequestCurrent(requestGeneration, requestIdentity)) return@launch _uiState.update { - it.copy( - isLoadingCatalog = false, - isLoadingMoreCatalog = false, - catalogError = "Network error: ${result.exception.message ?: "unknown"}", - ) + if (isCatalogRequestCurrent(requestGeneration, requestIdentity, it)) { + it.copy( + isLoadingCatalog = false, + isLoadingMoreCatalog = false, + catalogError = "Network error: ${result.exception.message ?: "unknown"}", + ) + } else { + it + } } } } @@ -565,49 +619,108 @@ class LibrariesViewModel( return } collectionsLoadedLibraryId = libraryId + val requestGeneration = ++collectionsRequestGeneration viewModelScope.launch { + if (!isCollectionsRequestCurrent(requestGeneration, libraryId)) return@launch _uiState.update { - it.copy( - isLoadingCollections = true, - collectionsError = null, - collections = emptyList(), - ) + if (isCollectionsRequestCurrent(requestGeneration, libraryId, it)) { + it.copy( + isLoadingCollections = true, + collectionsError = null, + collections = emptyList(), + ) + } else { + it + } } when (val result = sectionRepository.getLibraryCollections(libraryId)) { is ApiResult.Success -> { + if (!isCollectionsRequestCurrent(requestGeneration, libraryId)) return@launch _uiState.update { - it.copy( - isLoadingCollections = false, - collections = result.data, - collectionsError = null, - ) + if (isCollectionsRequestCurrent(requestGeneration, libraryId, it)) { + it.copy( + isLoadingCollections = false, + collections = result.data, + collectionsError = null, + ) + } else { + it + } } } is ApiResult.Error -> { + if (!isCollectionsRequestCurrent(requestGeneration, libraryId)) return@launch _uiState.update { - it.copy( - isLoadingCollections = false, - collectionsError = result.message.ifBlank { "Failed to load collections" }, - ) + if (isCollectionsRequestCurrent(requestGeneration, libraryId, it)) { + it.copy( + isLoadingCollections = false, + collectionsError = result.message.ifBlank { "Failed to load collections" }, + ) + } else { + it + } } } is ApiResult.NetworkError -> { + if (!isCollectionsRequestCurrent(requestGeneration, libraryId)) return@launch _uiState.update { - it.copy( - isLoadingCollections = false, - collectionsError = "Network error: ${result.exception.message ?: "unknown"}", - ) + if (isCollectionsRequestCurrent(requestGeneration, libraryId, it)) { + it.copy( + isLoadingCollections = false, + collectionsError = "Network error: ${result.exception.message ?: "unknown"}", + ) + } else { + it + } } } } } } -} -// Chrome metrics: status bar + this constant ≈ visible chrome height (header -// row + tab selector). Used to push tab content below the floating chrome and -// to drive the carousel's [extraTopInset] on the Recommended tab. -private val LibrariesChromeContentHeight: Dp = 110.dp + private fun isRecommendedRequestCurrent( + generation: Long, + libraryId: Int, + state: LibrariesUiState = _uiState.value, + ): Boolean = + generation == recommendedRequestGeneration && state.selectedLibraryId == libraryId + + private fun isCatalogRequestCurrent( + generation: Long, + identity: CatalogRequestIdentity, + state: LibrariesUiState = _uiState.value, + ): Boolean = + generation == catalogRequestGeneration && + identity.matches(state) + + private fun isCatalogQueryCurrent( + generation: Long, + identity: CatalogRequestIdentity, + state: LibrariesUiState = _uiState.value, + ): Boolean = + generation == catalogQueryGeneration && + identity.matches(state) + + private fun CatalogRequestIdentity.matches(state: LibrariesUiState): Boolean = + state.selectedLibraryId == libraryId && + state.browseSort == browseSort && + state.selectedNamePrefix == selectedNamePrefix && + state.filterState == filterState + + private fun isCollectionsRequestCurrent( + generation: Long, + libraryId: Int, + state: LibrariesUiState = _uiState.value, + ): Boolean = + generation == collectionsRequestGeneration && state.selectedLibraryId == libraryId + + private data class CatalogRequestIdentity( + val libraryId: Int, + val browseSort: LibraryBrowseSort, + val selectedNamePrefix: String?, + val filterState: CatalogFilterState, + ) +} // Distance the Recommended tab must scroll for the chrome scrim to fully // fade in. Mirrors `chromeScrimFadeDistance` on iOS. @@ -623,7 +736,7 @@ fun LibrariesScreen( onLibrarySelectorClick: () -> Unit, onSearchClick: () -> Unit, onRequestsClick: (() -> Unit)?, - onLiveTvClick: (() -> Unit)?, + onWatchTogetherClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, @@ -688,86 +801,105 @@ fun LibrariesScreen( ) } - when { - state.isLoadingLibraries && state.libraries.isEmpty() -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } - state.librariesError != null && state.libraries.isEmpty() -> { - ErrorView( - message = state.librariesError ?: "Failed to load libraries", - onRetry = viewModel::refresh, - modifier = Modifier.fillMaxSize(), - ) - } - selectedLibrary == null -> { - EmptyStateView( - title = "No libraries available", - subtitle = "Libraries visible to this profile will show up here", - icon = Icons.Default.VideoLibrary, - modifier = Modifier.fillMaxSize(), - ) - } - else -> { - when (state.selectedTab) { - LibrariesSubtab.Recommended -> RecommendedTabContent( - state = state, - listState = recommendedListState, - onItemClick = onItemClick, - onPlayClick = onPlayClick, - onRetry = viewModel::retryCurrentTab, - onActiveBackdropChange = { url, thumbhash -> - heroBackdropUrl = url - heroBackdropThumbhash = thumbhash - }, - ) - LibrariesSubtab.Browse -> BrowseTabContent( - state = state, - onItemClick = onItemClick, - onRetry = viewModel::retryCurrentTab, - onLoadMore = viewModel::loadMoreCatalog, - onSortChanged = viewModel::selectBrowseSort, - onNamePrefixChanged = viewModel::selectNamePrefix, - onDensityChanged = viewModel::selectViewDensity, - onApplyFilter = viewModel::applyFilterState, - onSetPreserve = viewModel::setPreserveFilters, - ) - LibrariesSubtab.Collections -> CollectionsTabContent( - state = state, - onCollectionClick = { collectionId -> - state.selectedLibraryId?.let { libraryId -> - onCollectionClick(collectionId, libraryId) - } - }, - onRetry = viewModel::retryCurrentTab, - ) + Column(modifier = Modifier.fillMaxSize()) { + LibrariesFloatingChrome( + scrimProgress = chromeScrimProgress, + selectedLibrary = selectedLibrary, + canSwitch = state.libraries.size > 1, + activeProfile = activeProfile, + selectedTab = state.selectedTab, + onLibrarySelectorClick = onLibrarySelectorClick, + onTabSelected = viewModel::selectTab, + onSearchClick = onSearchClick, + onRequestsClick = onRequestsClick, + onWatchTogetherClick = onWatchTogetherClick, + onSettingsClick = onSettingsClick, + onSwitchProfileClick = onSwitchProfileClick, + onSwitchServerClick = onSwitchServerClick, + onSignOutClick = onSignOutClick, + ) + + LibraryContentViewport( + modifier = Modifier.weight(1f).clipToBounds(), + ) { + when { + state.isLoadingLibraries && state.libraries.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + state.librariesError != null && state.libraries.isEmpty() -> { + ErrorView( + message = state.librariesError ?: "Failed to load libraries", + onRetry = viewModel::refresh, + modifier = Modifier.fillMaxSize(), + ) + } + selectedLibrary == null -> { + EmptyStateView( + title = "No libraries available", + subtitle = "Libraries visible to this profile will show up here", + icon = Icons.Default.VideoLibrary, + modifier = Modifier.fillMaxSize(), + ) + } + state.selectedTab == LibrariesSubtab.Recommended -> { + RecommendedTabContent( + state = state, + listState = recommendedListState, + onItemClick = onItemClick, + onPlayClick = onPlayClick, + onRetry = viewModel::retryCurrentTab, + onActiveBackdropChange = { url, thumbhash -> + heroBackdropUrl = url + heroBackdropThumbhash = thumbhash + }, + ) + } + state.selectedTab == LibrariesSubtab.Browse -> { + BrowseTabContent( + state = state, + onItemClick = onItemClick, + onRetry = viewModel::retryCurrentTab, + onLoadMore = viewModel::loadMoreCatalog, + onSortChanged = viewModel::selectBrowseSort, + onNamePrefixChanged = viewModel::selectNamePrefix, + onDensityChanged = viewModel::selectViewDensity, + onApplyFilter = viewModel::applyFilterState, + onSetPreserve = viewModel::setPreserveFilters, + ) + } + else -> { + CollectionsTabContent( + state = state, + onCollectionClick = { collectionId -> + state.selectedLibraryId?.let { libraryId -> + onCollectionClick(collectionId, libraryId) + } + }, + onRetry = viewModel::retryCurrentTab, + ) + } } } } - - LibrariesFloatingChrome( - scrimProgress = chromeScrimProgress, - selectedLibrary = selectedLibrary, - canSwitch = state.libraries.size > 1, - activeProfile = activeProfile, - selectedTab = state.selectedTab, - onLibrarySelectorClick = onLibrarySelectorClick, - onTabSelected = viewModel::selectTab, - onSearchClick = onSearchClick, - onRequestsClick = onRequestsClick, - onLiveTvClick = onLiveTvClick, - onSettingsClick = onSettingsClick, - onSwitchProfileClick = onSwitchProfileClick, - onSwitchServerClick = onSwitchServerClick, - onSignOutClick = onSignOutClick, - ) } } +@Composable +private fun LibraryContentViewport( + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + Box( + modifier = modifier.fillMaxWidth(), + content = content, + ) +} + @Composable private fun RecommendedTabContent( state: LibrariesUiState, @@ -780,20 +912,14 @@ private fun RecommendedTabContent( when { state.isLoadingSections && state.sections.isEmpty() -> { MediaRowsSkeleton( - modifier = Modifier - .fillMaxSize() - .padding(top = LibrariesChromeContentHeight) - .windowInsetsPadding(WindowInsets.statusBars), + modifier = Modifier.fillMaxSize(), ) } state.sectionsError != null && state.sections.isEmpty() -> { ErrorView( message = state.sectionsError ?: "Failed to load recommendations", onRetry = onRetry, - modifier = Modifier - .fillMaxSize() - .padding(top = LibrariesChromeContentHeight) - .windowInsetsPadding(WindowInsets.statusBars), + modifier = Modifier.fillMaxSize(), ) } state.sections.isEmpty() -> { @@ -801,10 +927,7 @@ private fun RecommendedTabContent( title = "No recommendations yet", subtitle = "Try switching libraries or browsing the full catalog", icon = libraryIcon(state.libraries.firstOrNull { it.id == state.selectedLibraryId }?.type.orEmpty()), - modifier = Modifier - .fillMaxSize() - .padding(top = LibrariesChromeContentHeight) - .windowInsetsPadding(WindowInsets.statusBars), + modifier = Modifier.fillMaxSize(), ) } else -> { @@ -826,21 +949,11 @@ private fun RecommendedTabContent( onPlayClick = onPlayClick, onInfoClick = onItemClick, onActiveBackdropChange = onActiveBackdropChange, - // Push the deck below the taller Libraries chrome - // (header + tab selector). Carousel already adds - // `statusBar + 64dp`; this covers the tab row. - extraTopInset = 50.dp, ) } } else { - // No featured → reserve runway under the chrome so the - // first row doesn't slide under the floating header. item(key = "no-featured") { - Spacer( - modifier = Modifier - .windowInsetsPadding(WindowInsets.statusBars) - .height(LibrariesChromeContentHeight + 8.dp), - ) + Spacer(modifier = Modifier.height(16.dp)) } } @@ -878,10 +991,7 @@ private fun BrowseTabContent( ) { var showFilterSheet by remember { mutableStateOf(false) } Column( - modifier = Modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.statusBars) - .padding(top = LibrariesChromeContentHeight), + modifier = Modifier.fillMaxSize(), ) { // Sort chips + a Filter button that opens the shared FilterSheet. Genre // is now a Categories facet inside the sheet (no inline genre rail, L3), @@ -990,6 +1100,7 @@ private fun BrowseTabContent( selectedNamePrefix = state.selectedNamePrefix, onNamePrefixSelected = onNamePrefixChanged, viewDensity = state.catalogDensity, + bottomContentInset = LocalBottomChromeInset.current, modifier = Modifier.fillMaxSize(), ) } @@ -1051,24 +1162,18 @@ private fun CollectionsTabContent( onCollectionClick: (String) -> Unit, onRetry: () -> Unit, ) { - val contentTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + - LibrariesChromeContentHeight when { state.isLoadingCollections && state.collections.isEmpty() -> { PosterGridSkeleton( progress = rememberShimmerProgress(), - modifier = Modifier - .fillMaxSize() - .padding(top = contentTopPadding), + modifier = Modifier.fillMaxSize(), ) } state.collectionsError != null && state.collections.isEmpty() -> { ErrorView( message = state.collectionsError ?: "Failed to load collections", onRetry = onRetry, - modifier = Modifier - .fillMaxSize() - .padding(top = contentTopPadding), + modifier = Modifier.fillMaxSize(), ) } state.collections.isEmpty() -> { @@ -1076,9 +1181,7 @@ private fun CollectionsTabContent( title = "No collections found", subtitle = "This library does not have any collections yet", icon = Icons.Default.VideoLibrary, - modifier = Modifier - .fillMaxSize() - .padding(top = contentTopPadding), + modifier = Modifier.fillMaxSize(), ) } else -> { @@ -1092,7 +1195,7 @@ private fun CollectionsTabContent( contentPadding = PaddingValues( start = 16.dp, end = 16.dp, - top = contentTopPadding, + top = 16.dp, bottom = 24.dp + LocalBottomChromeInset.current, ), ) { @@ -1118,7 +1221,7 @@ private fun InlineLibraryCollectionCard( ) { // iOS `LibraryCollectionCard`: VStack(spacing: 6) of a 2:3.3 poster // (smallCornerRadius = 6) carrying a bottom-trailing count badge, a - // prairieCaption (12) name (2 lines), and a prairieSmall (11) secondary + // siloCaption (12) name (2 lines), and a siloSmall (11) secondary // type label. Column( modifier = Modifier.clickable(onClick = onClick), @@ -1178,7 +1281,7 @@ private fun LibrariesFloatingChrome( onTabSelected: (LibrariesSubtab) -> Unit, onSearchClick: () -> Unit, onRequestsClick: (() -> Unit)?, - onLiveTvClick: (() -> Unit)?, + onWatchTogetherClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, @@ -1235,7 +1338,7 @@ private fun LibrariesFloatingChrome( ChromeProfileMenu( activeProfile = activeProfile, onRequestsClick = onRequestsClick, - onLiveTvClick = onLiveTvClick, + onWatchTogetherClick = onWatchTogetherClick, onSettingsClick = onSettingsClick, onSwitchProfileClick = onSwitchProfileClick, onSwitchServerClick = onSwitchServerClick, @@ -1273,7 +1376,7 @@ private fun LibrarySelectorButton( modifier: Modifier = Modifier, ) { // iOS `LibrarySelectorButton`: VStack(spacing: 1) of a name+chevron row - // (prairieTitle = 18pt bold) above a prairieCaption (12pt) type label. + // (siloTitle = 18pt bold) above a siloCaption (12pt) type label. Column( modifier = modifier .clickable(enabled = canSwitch && library != null, onClick = onClick) @@ -1333,7 +1436,7 @@ private fun ChromeIconButton( private fun ChromeProfileMenu( activeProfile: Profile?, onRequestsClick: (() -> Unit)?, - onLiveTvClick: (() -> Unit)?, + onWatchTogetherClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, @@ -1377,18 +1480,16 @@ private fun ChromeProfileMenu( }, ) } - if (onLiveTvClick != null) { + if (onWatchTogetherClick != null) { DropdownMenuItem( - text = { Text("Live TV") }, + text = { Text("Watch Together") }, onClick = { menuExpanded = false - onLiveTvClick() + onWatchTogetherClick() }, ) } - if (onRequestsClick != null || onLiveTvClick != null) { - HorizontalDivider() - } + HorizontalDivider() DropdownMenuItem( text = { Text("Settings") }, onClick = { @@ -1504,7 +1605,7 @@ private fun LibrarySubtabChip( selected: Boolean, onClick: () -> Unit, ) { - // iOS `LibraryPageTabSelector` chip: Capsule, prairieCaption (12pt), + // iOS `LibraryPageTabSelector` chip: Capsule, siloCaption (12pt), // selected = onSurface (white #EDEDED) fill / background (black) label and // semibold weight; unselected = surfaceElevated (#15171C) fill / secondary // label and regular weight. Padding h16 v8. @@ -1514,7 +1615,7 @@ private fun LibrarySubtabChip( color = if (selected) { MaterialTheme.colorScheme.onSurface } else { - PrairieSurfaceElevated + SiloSurfaceElevated }, contentColor = if (selected) { MaterialTheme.colorScheme.background @@ -1538,9 +1639,9 @@ private fun LibrarySelectorRow( onClick: () -> Unit, ) { // iOS `LibraryPickerRow`: cornerRadius (8) card, selected fill onSurface@10% - // else surfaceElevated, hairline prairieOutline (white@12%) border. Row + // else surfaceElevated, hairline siloOutline (white@12%) border. Row // padding h16 v14; circle 40 onSurface@12% with an 18pt icon; name - // prairieHeadline (16) above a prairieCaption (12) secondary label; + // siloHeadline (16) above a siloCaption (12) secondary label; // a 14pt checkmark on the selected row. Surface( onClick = onClick, @@ -1548,7 +1649,7 @@ private fun LibrarySelectorRow( color = if (isSelected) { MaterialTheme.colorScheme.onSurface.copy(alpha = 0.10f) } else { - PrairieSurfaceElevated + SiloSurfaceElevated }, contentColor = MaterialTheme.colorScheme.onSurface, border = BorderStroke( diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/onboarding/OnboardingTourLocalCache.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/onboarding/OnboardingTourLocalCache.kt new file mode 100644 index 000000000..0d0c26a3d --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/onboarding/OnboardingTourLocalCache.kt @@ -0,0 +1,29 @@ +package org.prairieserver.prairie.android.ui.screens.onboarding + +import android.content.Context + +/** + * Local record of "this profile finished (or skipped) the tour", keyed per + * server + profile. The server stays the source of truth — this only lets + * the app skip the blocking state fetch on every profile selection once the + * answer is known to be "done". It is set on a server-confirmed done state + * or a locally initiated complete/skip, never on a failed check, so a + * network error can't silence a tour that is still pending. + */ +class OnboardingTourLocalCache(context: Context) { + + private val prefs = + context.getSharedPreferences("onboarding_tour", Context.MODE_PRIVATE) + + private fun key(serverId: String?, profileId: String?): String? { + if (serverId.isNullOrBlank() || profileId.isNullOrBlank()) return null + return "done:$serverId:$profileId" + } + + fun isDone(serverId: String?, profileId: String?): Boolean = + key(serverId, profileId)?.let { prefs.getBoolean(it, false) } ?: false + + fun markDone(serverId: String?, profileId: String?) { + key(serverId, profileId)?.let { prefs.edit().putBoolean(it, true).apply() } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/onboarding/OnboardingTourScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/onboarding/OnboardingTourScreen.kt new file mode 100644 index 000000000..050304720 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/onboarding/OnboardingTourScreen.kt @@ -0,0 +1,370 @@ +package org.prairieserver.prairie.android.ui.screens.onboarding + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.PlayCircle +import androidx.compose.material.icons.filled.Subtitles +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.util.lerp +import kotlin.math.abs +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.android.ui.components.aurora.AuroraAccent +import org.prairieserver.prairie.android.ui.components.aurora.AuroraGhostButton +import org.prairieserver.prairie.android.ui.components.aurora.AuroraInk +import org.prairieserver.prairie.android.ui.components.aurora.AuroraInkSecondary +import org.prairieserver.prairie.android.ui.components.aurora.AuroraPrimaryButton +import org.prairieserver.prairie.android.ui.components.aurora.AuroraScreen +import org.prairieserver.prairie.android.ui.components.aurora.AuroraScrim +import org.prairieserver.prairie.android.ui.components.aurora.AuroraVariant +import org.prairieserver.prairie.android.ui.components.aurora.auroraGlass +import org.prairieserver.prairie.model.onboarding.OnboardingStep + +/** Gutter the fixed rows and the pager's peek share. */ +private val TourGutter = 20.dp + +/** Client-side illustration keys — the server only ever names them. */ +private fun illustrationFor(key: String?): ImageVector = when (key) { + "watchlist" -> Icons.Filled.Favorite + "watch-together" -> Icons.Filled.Groups + "calendar" -> Icons.Filled.CalendarMonth + "playback" -> Icons.Filled.PlayCircle + "subtitles" -> Icons.Filled.Subtitles + else -> Icons.Filled.AutoAwesome +} + +/** + * Server-driven first-run tour: one step per page, skip always reachable. + * Kind filtering happened in the ViewModel; by the time a step renders here + * it is one of the known kinds. + */ +@Composable +fun OnboardingTourScreen( + onDone: () -> Unit, + viewModel: OnboardingTourViewModel = koinViewModel(), +) { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { viewModel.load() } + + LaunchedEffect(state.finished) { + if (state.finished) onDone() + } + + // scrollable = false: this screen sizes itself against the display — pips + // and buttons pinned, the cards taking the space between them. Inside a + // scrolling parent that weighted body would collapse to nothing. + // horizontalPadding = 0: the pager runs full-bleed so the next card peeks + // in from the edge; the fixed rows re-apply the gutter themselves. + AuroraScreen( + variant = AuroraVariant.SignIn, + scrim = AuroraScrim.Soft, + scrollable = false, + horizontalPadding = 0.dp, + ) { + if (state.isLoading || state.steps.isEmpty()) { + // Skip stays reachable while the manifest loads: this gate sits + // between profile selection and Home with the back stack already + // cleared, so a slow server must never hold the app hostage for + // the full request timeout. + Column(Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TourGutter), + horizontalArrangement = Arrangement.End, + ) { + AuroraGhostButton(label = "Skip", onClick = viewModel::onSkip) + } + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = Color(0xFFF3EFE9)) + } + } + return@AuroraScreen + } + + val step = state.steps[state.currentIndex] + val isLast = state.currentIndex == state.steps.lastIndex + + val pagerState = rememberPagerState( + // The tour can resume mid-way, so the pager has to open on the + // step the ViewModel restored rather than snapping to zero. + initialPage = state.currentIndex, + pageCount = { state.steps.size }, + ) + + // Pager -> ViewModel: only once a swipe has settled, so a drag the user + // releases halfway doesn't record a step they never actually saw. + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage } + .collect { viewModel.onPageSettled(it) } + } + // ViewModel -> pager: Back/Next (and the resume index) drive the pager + // so buttons and swipes stay one shared position. Guarded on the + // pager's target, not its settled page — mid-fling the two disagree and + // an unguarded call here would cancel the user's own swipe. + LaunchedEffect(state.currentIndex) { + if (pagerState.targetPage != state.currentIndex) { + pagerState.animateScrollToPage(state.currentIndex) + } + } + + Column(modifier = Modifier.fillMaxSize()) { + // Progress pips + skip + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TourGutter), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.weight(1f), + ) { + state.steps.forEachIndexed { index, _ -> + Box( + modifier = Modifier + .height(6.dp) + .width(if (index == state.currentIndex) 18.dp else 6.dp) + .background( + color = if (index == state.currentIndex) { + Color(0xFFF3EFE9) + } else { + Color.White.copy(alpha = 0.25f) + }, + shape = CircleShape, + ), + ) + } + } + AuroraGhostButton(label = "Skip", onClick = viewModel::onSkip) + } + + Spacer(Modifier.height(24.dp)) + + HorizontalPager( + state = pagerState, + modifier = Modifier.weight(1f), + // Inset the pages rather than the pager: the pager itself keeps + // the full width, so the neighbouring card slides in from the + // display edge instead of being clipped at a gutter. + contentPadding = PaddingValues(horizontal = TourGutter), + pageSpacing = 12.dp, + verticalAlignment = Alignment.CenterVertically, + ) { page -> + // Distance of this page from the settled position: 0 while it + // is the current card, ±1 once fully a neighbour. Driven by the + // live scroll offset so the card tracks the finger. + val offset = ( + (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction + ).coerceIn(-1f, 1f) + val distance = abs(offset) + + TourStepPage( + step = state.steps[page], + selected = state.pendingChoices[state.steps[page].id] ?: "", + onChosen = viewModel::onSettingChosen, + modifier = Modifier.graphicsLayer { + // Neighbours sit slightly back and dimmed, so the stack + // reads as depth rather than a filmstrip. + val scale = lerp(0.92f, 1f, 1f - distance) + scaleX = scale + scaleY = scale + alpha = lerp(0.5f, 1f, 1f - distance) + }, + ) + } + + Spacer(Modifier.height(20.dp)) + + val isTerminal = step.kind == "handoff" || isLast + Row( + modifier = Modifier.padding(horizontal = TourGutter), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (state.currentIndex > 0) { + AuroraGhostButton(label = "Back", onClick = viewModel::onBack) + } + AuroraPrimaryButton( + label = when { + isTerminal -> "Done" + state.currentIndex == 0 -> "Show me" + else -> "Next" + }, + onClick = if (isTerminal) viewModel::onFinish else viewModel::onAdvance, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +/** + * One tour card. Rendered per pager page rather than for the current step + * alone, so the neighbouring pages are already drawn as a swipe reveals them. + */ +@Composable +private fun TourStepPage( + step: OnboardingStep, + selected: String, + onChosen: (OnboardingStep, String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + // No drop shadow: a full-height translucent card lets the shadow's + // own edge show through as a faint box, and it tracks the card + // across a swipe. + .auroraGlass(cornerRadius = 28.dp, elevation = 0.dp) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 32.dp), + verticalArrangement = Arrangement.Center, + ) { + Box( + modifier = Modifier + .size(64.dp) + .background( + Brush.linearGradient( + listOf(AuroraAccent.copy(alpha = 0.28f), AuroraAccent.copy(alpha = 0.06f)), + ), + RoundedCornerShape(20.dp), + ) + .border(1.dp, AuroraAccent.copy(alpha = 0.30f), RoundedCornerShape(20.dp)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = illustrationFor(step.illustration), + contentDescription = null, + tint = AuroraAccent, + modifier = Modifier.size(30.dp), + ) + } + Spacer(Modifier.height(24.dp)) + step.title?.let { + Text( + text = it, + fontSize = 27.sp, + fontWeight = FontWeight.SemiBold, + color = AuroraInk, + lineHeight = 34.sp, + ) + } + step.body?.let { + Spacer(Modifier.height(12.dp)) + Text( + text = it, + fontSize = 15.sp, + color = AuroraInkSecondary, + lineHeight = 23.sp, + ) + } + + if (step.kind == "setting_choice" && step.setting != null) { + Spacer(Modifier.height(22.dp)) + SettingChoiceCard(step = step, selected = selected, onChosen = onChosen) + } + } +} + +/** + * Renders the manifest's options as a tappable list. Selection lives in the + * ViewModel (persisted when the user advances past the step), so the + * highlight can never disagree with what gets saved. + */ +@Composable +private fun SettingChoiceCard( + step: OnboardingStep, + selected: String, + onChosen: (OnboardingStep, String) -> Unit, +) { + val spec = step.setting ?: return + + Column( + modifier = Modifier + .fillMaxWidth() + // A hairline well rather than another glass panel: this now sits + // inside the card's glass, and stacking the two muddies both. + .background(Color.White.copy(alpha = 0.04f), RoundedCornerShape(20.dp)) + .border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(20.dp)) + .padding(10.dp) + .animateContentSize(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + spec.options.forEach { option -> + val isSelected = option.value == selected + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = if (isSelected) Color.White.copy(alpha = 0.12f) else Color.Transparent, + shape = RoundedCornerShape(12.dp), + ) + .clickable { onChosen(step, option.value) } + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(16.dp) + .background( + color = if (isSelected) Color(0xFFF3EFE9) else Color.Transparent, + shape = CircleShape, + ) + .border( + width = 1.5.dp, + color = if (isSelected) Color(0xFFF3EFE9) else Color.White.copy(alpha = 0.4f), + shape = CircleShape, + ), + ) + Spacer(Modifier.width(12.dp)) + Text( + text = option.label, + fontSize = 15.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + color = Color(0xFFF3EFE9), + ) + } + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/onboarding/OnboardingTourViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/onboarding/OnboardingTourViewModel.kt new file mode 100644 index 000000000..5b3e2f2e7 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/onboarding/OnboardingTourViewModel.kt @@ -0,0 +1,289 @@ +package org.prairieserver.prairie.android.ui.screens.onboarding + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.prairieserver.prairie.common.settings.PlayerSettingsStore +import org.prairieserver.prairie.model.onboarding.OnboardingFlow +import org.prairieserver.prairie.model.onboarding.OnboardingStep +import org.prairieserver.prairie.model.profile.UpdateProfileRequest +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.repository.OnboardingRepository +import org.prairieserver.prairie.repository.ProfileRepository + +/** Step kinds this client can render; anything else is dropped at load. */ +private val KNOWN_KINDS = setOf("welcome", "feature_card", "setting_choice", "handoff") + +data class OnboardingTourUiState( + val isLoading: Boolean = true, + /** Empty after load with [finished] set = nothing to show. */ + val steps: List = emptyList(), + val tourId: String = "", + val currentIndex: Int = 0, + /** + * setting_choice values picked (or defaulted) but not yet persisted. + * Written when the user advances past the step, so what the card shows + * as selected is exactly what gets saved. + */ + val pendingChoices: Map = emptyMap(), + val finished: Boolean = false, +) + +/** + * Drives the server-driven first-run tour. Progress and completion post to + * the server per profile, so finishing here silences the web and TV too. + * setting_choice steps write through the existing profile-update path. + */ +class OnboardingTourViewModel( + private val onboardingRepository: OnboardingRepository, + private val profileRepository: ProfileRepository, + private val playerSettingsStore: PlayerSettingsStore, + private val tokenManager: TokenManager, + private val localCache: OnboardingTourLocalCache, +) : ViewModel() { + + private val _uiState = MutableStateFlow(OnboardingTourUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var loadStarted = false + + fun load() { + // The screen calls this from a LaunchedEffect that re-runs on every + // composition restart (rotation, theme change); without the guard a + // mid-tour user would be re-fetched back to step 1. + if (loadStarted) return + loadStarted = true + viewModelScope.launch { + // Known-done locally: skip the network entirely. The flag is only + // ever set from a server-confirmed done state or our own + // complete/skip, so trusting it can't hide a pending tour. + if (localCache.isDone(tokenManager.getCurrentServerId(), tokenManager.getProfileId())) { + _uiState.update { it.copy(isLoading = false, finished = true) } + return@launch + } + // Fetch state and manifest together — the manifest is discarded + // when state says done, but that waste is cheaper than serializing + // two round trips in front of first render. + val stateDeferred = async { onboardingRepository.getState() } + val flowDeferred = async { onboardingRepository.getFlow(surface = "phone") } + val resumeStep: String? + when (val state = stateDeferred.await()) { + is ApiResult.Success -> { + if (state.data.done) { + // Server-confirmed done — safe to cache locally. + markDoneLocally() + flowDeferred.cancel() + _uiState.update { it.copy(isLoading = false, finished = true) } + return@launch + } + resumeStep = state.data.lastStep + } + // On any error, skip the tour rather than block first run — + // but don't cache done: the server was never consulted. + is ApiResult.Error, is ApiResult.NetworkError -> { + flowDeferred.cancel() + _uiState.update { it.copy(isLoading = false, finished = true) } + return@launch + } + } + when (val flow = flowDeferred.await()) { + is ApiResult.Success -> applyFlow(flow.data, resumeStep) + is ApiResult.Error, is ApiResult.NetworkError -> { + _uiState.update { it.copy(isLoading = false, finished = true) } + } + } + } + } + + private suspend fun applyFlow(flow: OnboardingFlow, resumeStep: String?) { + val steps = flow.steps.filter { it.kind in KNOWN_KINDS } + if (steps.isEmpty()) { + // Nothing renderable: mark complete so we never loop — locally + // only once the server acknowledged, so an offline auto-complete + // is retried next launch instead of silently diverging. + _uiState.update { it.copy(isLoading = false, finished = true) } + // Snapshot before the POST: finished=true navigates to Home, where + // the active profile can change while this is still in flight. + val serverId = tokenManager.getCurrentServerId() + val profileId = tokenManager.getProfileId() + withContext(NonCancellable) { + if (onboardingRepository.complete(flow.tourId, null) is ApiResult.Success) { + localCache.markDone(serverId, profileId) + } + } + return + } + // Seed each setting_choice with its manifest default so a user who + // accepts what the card already shows still has it persisted on + // advance. + val defaults = steps + .filter { it.kind == "setting_choice" } + .mapNotNull { step -> step.setting?.default?.let { step.id to it } } + .toMap() + // Progress recorded from another device or before a process death: + // resume at the server's last-seen step instead of step one. + val startIndex = resumeStep + ?.let { last -> steps.indexOfFirst { it.id == last } } + ?.takeIf { it >= 0 } + ?: 0 + _uiState.update { + it.copy( + isLoading = false, + steps = steps, + tourId = flow.tourId, + currentIndex = startIndex, + pendingChoices = defaults, + ) + } + } + + fun onAdvance() { + val current = _uiState.value + val next = current.currentIndex + 1 + if (next >= current.steps.size) { + finish(skipped = false, persistCurrentChoice = true) + return + } + moveTo(next) + } + + fun onBack() { + moveTo(_uiState.value.currentIndex - 1) + } + + /** + * Settle handler for swipe navigation. The pager is the one that moved, so + * this only reconciles state; the screen must not echo it back as a scroll + * or the two chase each other. + */ + fun onPageSettled(index: Int) { + if (index == _uiState.value.currentIndex) return + moveTo(index) + } + + /** + * Single path for every index change — button or swipe — so a step reached + * by swiping records and persists exactly like one reached by tapping. + */ + private fun moveTo(target: Int) { + val current = _uiState.value + val index = target.coerceIn(0, current.steps.lastIndex.coerceAtLeast(0)) + if (index == current.currentIndex) return + // Advancing past a setting_choice commits it; going back doesn't, so a + // user who swipes backwards to reconsider isn't saving on the way out. + if (index > current.currentIndex) { + persistChoiceIfAny(current.steps.getOrNull(current.currentIndex)) + viewModelScope.launch { + current.steps.getOrNull(index)?.let { + onboardingRepository.recordStep(current.tourId, it.id) + } + } + } + _uiState.update { it.copy(currentIndex = index) } + } + + fun onSkip() = finish(skipped = true, persistCurrentChoice = false) + + fun onFinish() = finish(skipped = false, persistCurrentChoice = true) + + private fun finish(skipped: Boolean, persistCurrentChoice: Boolean) { + val current = _uiState.value + if (persistCurrentChoice) { + persistChoiceIfAny(current.steps.getOrNull(current.currentIndex)) + } + // Skip tapped while the manifest is still loading: there is no tour + // id to post against. Let the user through; server state stays + // not-done, so the tour is simply offered again another time. + if (current.tourId.isBlank()) { + _uiState.update { it.copy(finished = true) } + return + } + viewModelScope.launch { + val lastStep = current.steps.getOrNull(current.currentIndex)?.id + // Snapshot the identity that is finishing the tour. finished=true + // navigates to Home, where the user can switch profile or server + // while this POST is still in flight — reading the token manager on + // acknowledgement would then mark whichever profile is active by + // then, letting it skip a tour it never saw. + val serverId = tokenManager.getCurrentServerId() + val profileId = tokenManager.getProfileId() + // finished=true (below) navigates away with popUpTo, which clears + // this ViewModel and cancels its scope — the POST must survive + // that or the server never learns the tour ended and re-shows it. + // The local done-cache is written only on the server's ack: if + // the POST is lost, the next launch re-consults the server and + // retries the tour rather than silently diverging from every + // other client. + withContext(NonCancellable) { + val result = if (skipped) { + onboardingRepository.skip(current.tourId, lastStep) + } else { + onboardingRepository.complete(current.tourId, lastStep) + } + if (result is ApiResult.Success) { + localCache.markDone(serverId, profileId) + } + } + } + _uiState.update { it.copy(finished = true) } + } + + private fun markDoneLocally() { + viewModelScope.launch { + withContext(NonCancellable) { + localCache.markDone(tokenManager.getCurrentServerId(), tokenManager.getProfileId()) + } + } + } + + /** Records a tapped option locally; nothing is written until advance. */ + fun onSettingChosen(step: OnboardingStep, value: String) { + _uiState.update { it.copy(pendingChoices = it.pendingChoices + (step.id to value)) } + } + + /** + * Writes one setting_choice value. UpdateProfileRequest is typed per + * field, so the manifest's string key maps onto the matching field; + * unknown keys (a newer server) are ignored rather than failing the + * tour. Only profile_field targets exist for phones today. + */ + private fun persistChoiceIfAny(step: OnboardingStep?) { + val spec = step?.setting ?: return + if (spec.target != "profile_field") return + val value = _uiState.value.pendingChoices[step.id] ?: return + val request = when (spec.key) { + "quality_preference" -> UpdateProfileRequest(qualityPreference = value) + "subtitle_language" -> UpdateProfileRequest(subtitleLanguage = value) + "subtitle_mode" -> UpdateProfileRequest(subtitleMode = value) + "auto_skip_intro" -> UpdateProfileRequest(autoSkipIntro = value.toBoolean()) + "auto_skip_credits" -> UpdateProfileRequest(autoSkipCredits = value.toBoolean()) + else -> return + } + viewModelScope.launch { + withContext(NonCancellable) { + // Android playback and the Settings screen read quality and + // auto-skip from the local player settings store, not the + // profile fields — mirror those there too or the choice the + // tour just showed has no visible effect in this app. + when (spec.key) { + "quality_preference" -> playerSettingsStore.setPreferredQuality(value) + "auto_skip_intro" -> playerSettingsStore.setAutoSkipIntro(value.toBoolean()) + "auto_skip_credits" -> playerSettingsStore.setAutoSkipCredits(value.toBoolean()) + } + // Best-effort against the profile: the local store above is + // what this device plays back with, and Settings re-syncs + // from the server later. A rejected PUT here shouldn't trap + // the user in a tour they can't leave. + profileRepository.updateActiveProfile(request) + } + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobilePlayerRouteTarget.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobilePlayerRouteTarget.kt new file mode 100644 index 000000000..3adb748c7 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobilePlayerRouteTarget.kt @@ -0,0 +1,248 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.playback.encodeSubtitleIdentityPreference +import org.prairieserver.prairie.playback.resolveCatalogSubtitlePreferenceOrdinal + +/** + * The route-level choices that produced the current mobile player target. + * + * Explicitness is kept separately from the value because a null route argument + * means "keep using automatic selection", not "the resolved player has no + * file/track". This lets a bare repeated play link remain idempotent after its + * first automatic resolution while still detecting a later explicit in-player + * version or track choice. + */ +internal data class MobilePlayerRouteIntent( + val contentId: String, + val fileId: Int? = null, + val quality: String? = null, + val audioTrackIndex: Int? = null, + val subtitleTrackIndex: Int? = null, + val resumePositionSeconds: Double? = null, + val fileIsExplicit: Boolean = fileId != null, + val qualityIsExplicit: Boolean = quality != null, + val audioTrackIsExplicit: Boolean = audioTrackIndex != null, + val subtitleTrackIsExplicit: Boolean = subtitleTrackIndex != null, +) + +/** Route intent plus the values the player is currently loading or playing. */ +internal data class MobilePlayerRouteTarget( + val intent: MobilePlayerRouteIntent, + val contentId: String, + val fileId: Int?, + val quality: String?, + val audioTrackIndex: Int?, + val subtitleTrackIndex: Int?, + val resumePositionSeconds: Double?, +) + +/** + * Owns route provenance independently from the resolved player state. + * + * Track choices are staged until the subtitle/audio transaction reports the + * requested value as committed. Internal persisted/automatic restores never + * call the staging methods, so they cannot become explicit route choices. + */ +internal class MobilePlayerRouteIntentState { + private data class PendingAudioSelection( + val contentId: String, + val routeOrdinal: Int, + val serverIndex: Int, + ) + + private data class PendingSubtitleSelection( + val contentId: String, + val routeOrdinal: Int?, + val identity: SubtitleIdentity, + ) + + private data class VersionSelection( + val contentId: String, + val previous: MobilePlayerRouteIntent, + ) + + var current: MobilePlayerRouteIntent? = null + private set + + private var pendingAudioSelection: PendingAudioSelection? = null + private var pendingSubtitleSelection: PendingSubtitleSelection? = null + private var versionSelection: VersionSelection? = null + + fun beginLoad( + contentId: String, + fileId: Int?, + quality: String?, + audioTrackIndex: Int?, + subtitleTrackIndex: Int?, + resumePositionSeconds: Double?, + preserveCurrent: Boolean, + ) { + clearPendingTrackSelections() + if (preserveCurrent && current != null) return + current = MobilePlayerRouteIntent( + contentId = contentId, + fileId = fileId, + quality = quality, + audioTrackIndex = audioTrackIndex, + subtitleTrackIndex = subtitleTrackIndex, + resumePositionSeconds = resumePositionSeconds, + ) + versionSelection = null + } + + fun beginVersionSelection(contentId: String, fileId: Int) { + val previous = current + ?.takeIf { it.contentId == contentId } + ?: MobilePlayerRouteIntent(contentId = contentId) + versionSelection = VersionSelection(contentId = contentId, previous = previous) + current = previous.copy(fileId = fileId, fileIsExplicit = true) + clearPendingTrackSelections() + } + + fun qualityForLoad( + contentId: String, + normalizedRequestedQuality: String?, + preserveCurrent: Boolean, + ): String? { + if (!preserveCurrent) return normalizedRequestedQuality + return current + ?.takeIf { it.contentId == contentId && it.qualityIsExplicit } + ?.quality + } + + fun recoverVersionSelection(contentId: String) { + val selection = versionSelection?.takeIf { it.contentId == contentId } ?: return + current = selection.previous + clearPendingTrackSelections() + } + + fun beginAudioSelection(contentId: String, routeOrdinal: Int, serverIndex: Int) { + pendingAudioSelection = PendingAudioSelection(contentId, routeOrdinal, serverIndex) + } + + fun beginSubtitleSelection( + contentId: String, + routeOrdinal: Int?, + identity: SubtitleIdentity, + ) { + pendingSubtitleSelection = PendingSubtitleSelection(contentId, routeOrdinal, identity) + } + + fun applyCommittedTracks( + contentId: String, + committedAudioServerIndex: Int?, + committedSubtitleIdentity: SubtitleIdentity, + transactionFailed: Boolean, + transactionActive: Boolean, + ) { + if (transactionFailed) { + clearPendingTrackSelections() + return + } + + pendingAudioSelection + ?.takeIf { it.contentId == contentId } + ?.let { pending -> + if (pending.serverIndex == committedAudioServerIndex) { + current + ?.takeIf { it.contentId == contentId } + ?.let { intent -> + current = intent.copy( + audioTrackIndex = pending.routeOrdinal, + audioTrackIsExplicit = true, + ) + } + pendingAudioSelection = null + } else if (!transactionActive) { + pendingAudioSelection = null + } + } + + pendingSubtitleSelection + ?.takeIf { it.contentId == contentId } + ?.let { pending -> + if (pending.identity == committedSubtitleIdentity) { + current + ?.takeIf { it.contentId == contentId } + ?.let { intent -> + current = intent.copy( + subtitleTrackIndex = pending.routeOrdinal, + subtitleTrackIsExplicit = true, + ) + } + pendingSubtitleSelection = null + } else if (!transactionActive) { + pendingSubtitleSelection = null + } + } + } + + fun clear() { + current = null + versionSelection = null + clearPendingTrackSelections() + } + + private fun clearPendingTrackSelections() { + pendingAudioSelection = null + pendingSubtitleSelection = null + } +} + +/** + * Produces one atomic target snapshot for external-route redelivery. + * + * While a load is pending, its explicit route intent is the only authoritative + * target. Once mounted, content/file/track values come from live player state; + * the quality intent remains route-owned because it is a playback ceiling, not + * necessarily the selected source file's resolution. + */ +internal fun mobilePlayerRouteTarget( + intent: MobilePlayerRouteIntent?, + state: PlayerViewModel.PlayerUiState, +): MobilePlayerRouteTarget? { + intent ?: return null + if (state.contentId.isBlank() || state.contentId != intent.contentId || state.error != null) { + return null + } + + if (state.isLoading) { + return MobilePlayerRouteTarget( + intent = intent, + contentId = state.contentId, + fileId = intent.fileId, + quality = intent.quality, + audioTrackIndex = intent.audioTrackIndex, + subtitleTrackIndex = intent.subtitleTrackIndex, + resumePositionSeconds = intent.resumePositionSeconds, + ) + } + + if (state.streamUrl.isNullOrBlank()) return null + val version = state.versions.getOrNull(state.selectedVersionIndex) + val catalogSubtitleOrdinal = resolveCatalogSubtitlePreferenceOrdinal( + tracks = version?.subtitleTracks.orEmpty(), + preference = encodeSubtitleIdentityPreference(state.committedSubtitleIdentity), + ) + return MobilePlayerRouteTarget( + intent = intent, + contentId = state.contentId, + fileId = state.mediaFileId, + quality = intent.quality, + audioTrackIndex = state.selectedAudioIndex.takeIf { it in state.audioTracks.indices }, + subtitleTrackIndex = catalogSubtitleOrdinal, + resumePositionSeconds = intent.resumePositionSeconds, + ) +} + +internal fun catalogSubtitleRouteOrdinal( + state: PlayerViewModel.PlayerUiState, + identity: org.prairieserver.prairie.model.playback.SubtitleIdentity, +): Int? = resolveCatalogSubtitlePreferenceOrdinal( + tracks = state.versions + .getOrNull(state.selectedVersionIndex) + ?.subtitleTracks + .orEmpty(), + preference = encodeSubtitleIdentityPreference(identity), +) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt index 5fd2eb439..3c7f5196e 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt @@ -202,6 +202,7 @@ internal class MobileSubtitleTransactionAdapter( private var pendingLocalRestore: PendingLocalRestore? = null private var localMountGeneration = 0L private var localMountTimeout: Job? = null + private var lastSettledLocalMountMissSnapshotKey: String? = null private val queuedMutations = mutableListOf() private var commitInFlight = false private var resetDuringCommit = false @@ -477,7 +478,7 @@ internal class MobileSubtitleTransactionAdapter( publish() persist(transition.committed, pendingSelection.context) } - } else if (settled && !snapshotKey.isNullOrBlank()) { + } else if (isStableLocalMountMiss(snapshotKey, settled)) { failLocalMount(pendingSelection.generation) } return @@ -490,11 +491,18 @@ internal class MobileSubtitleTransactionAdapter( failureMessage = null publish() persistence?.let { persist(it.committed, it.context) } - } else if (settled && !snapshotKey.isNullOrBlank()) { + } else if (isStableLocalMountMiss(snapshotKey, settled)) { failLocalMount(pendingRestore.generation) } } + private fun isStableLocalMountMiss(snapshotKey: String?, settled: Boolean): Boolean { + if (!settled || snapshotKey.isNullOrBlank()) return false + val stable = snapshotKey == lastSettledLocalMountMissSnapshotKey + lastSettledLocalMountMissSnapshotKey = snapshotKey + return stable + } + private fun mutate(event: SubtitleTransitionEvent, explicit: Boolean) { if (explicit) refreshGeneration += 1 subtitleIntentGeneration += 1 @@ -995,6 +1003,7 @@ internal class MobileSubtitleTransactionAdapter( localMountGeneration += 1 pendingLocalSelection = null pendingLocalRestore = null + lastSettledLocalMountMissSnapshotKey = null localMountTimeout?.cancel() localMountTimeout = null } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileVideoPlaybackStarter.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileVideoPlaybackStarter.kt index cb74aed8c..f1dd6bb05 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileVideoPlaybackStarter.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileVideoPlaybackStarter.kt @@ -24,6 +24,7 @@ import org.prairieserver.prairie.model.playback.buildPlaybackSubtitleChoices import org.prairieserver.prairie.model.playback.applyResumeRewind import org.prairieserver.prairie.model.playback.resolvePlaybackStartRequestPosition import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.playback.orNullIfBlank import org.prairieserver.prairie.playback.selectPlaybackVersion import org.prairieserver.prairie.repository.CatalogRepository import org.prairieserver.prairie.repository.ProfileRepository @@ -41,6 +42,8 @@ internal data class MobileVideoSessionAllocation( val subtitleTrackIndex: Int?, val qualityPreference: String?, val startPosition: Double?, + /** `playback.max_bitrate_kbps`; null is uncapped. */ + val maxBitrateKbps: Int? = null, ) internal fun interface MobileVideoSessionAllocator { @@ -100,6 +103,12 @@ internal class MobileVideoPlaybackStarter( val preferredQuality = request.preferredQualityOverride ?: playerSettingsStore.preferredQualityFlow.first() val playbackQualityIntent = request.playbackQualityIntent ?: preferredQuality + // The bandwidth half of the quality choice. Quality is two axes and + // the server applies the cap only from what the request carries — + // nothing on the playback path reads the stored setting — so + // sending the resolution alone lets a capped preset ("1080p Low") + // stream at the bandwidth the user explicitly declined. + val maxBitrateKbps = playerSettingsStore.maxBitrateKbpsFlow.first() val preferredAudioLanguage = playerSettingsStore.audioLanguageFlow .first().ifBlank { null } val version = request.preferredFileId @@ -164,6 +173,7 @@ internal class MobileVideoPlaybackStarter( subtitleTrackIndex = request.subtitleTrackIndex, qualityPreference = playbackQualityIntent, startPosition = startRequestPosition, + maxBitrateKbps = maxBitrateKbps, ), ) ?: playbackSessionManager.startVideoSessionV3( fileId = version.fileId, @@ -174,6 +184,7 @@ internal class MobileVideoPlaybackStarter( subtitleTrackIndex = request.subtitleTrackIndex, qualityPreference = playbackQualityIntent, startPosition = startRequestPosition, + maxBitrateKbps = maxBitrateKbps, ) ) { is ApiResult.Success -> r.data @@ -293,7 +304,30 @@ internal class MobileVideoPlaybackStarter( plannedTracks = resolved.subtitleUrls.orEmpty(), ), preferredAudioLanguage = preferredAudioLanguage ?: activeProfile?.language, - preferredTextLanguage = activeProfile?.subtitleLanguage, + // Server-resolved first, exactly as TvVideoPlaybackStarter does. + // The settings screens write these three canonically now + // (`PUT /settings/values/{key}?scope=profile`) and nothing + // mirrors a canonical write back into `user_profiles`, so the + // profile columns go stale the moment the user changes a + // subtitle preference. `effective_*` is what the server would + // resolve for this item; the columns stay only as the fallback + // for a server too old to send them. + // + // Blank is normalized to null on every rung, matching the audio + // language above. A canonical row holding JSON null (the + // contract's spelling of "no preference") unmarshals to "" on + // the server and arrives here as a present-but-empty string, and + // `resolveMobileAutoSubtitleSelection` reads a non-null blank + // language as an explicit "subtitles off" — so passing it + // through would turn auto-selection off for a user who never + // chose a language. + preferredTextLanguage = watchDetail.effectiveSubtitleLanguage.orNullIfBlank() + ?: activeProfile?.subtitleLanguage.orNullIfBlank(), + preferredSubtitleMode = watchDetail.effectiveSubtitleMode.orNullIfBlank() + ?: activeProfile?.subtitleMode.orNullIfBlank(), + showForcedSubtitles = watchDetail.effectiveShowForcedSubtitles + ?: activeProfile?.showForcedSubtitles + ?: true, intro = watchDetail.intro, credits = watchDetail.credits, chapters = effectiveVersion?.chapters.orEmpty(), diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerGestureHandler.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerGestureHandler.kt index a4fda929f..c50528208 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerGestureHandler.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerGestureHandler.kt @@ -2,8 +2,6 @@ package org.prairieserver.prairie.android.ui.screens.player import android.content.Context import android.media.AudioManager -import android.view.Window -import android.view.WindowManager import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -56,7 +54,7 @@ import kotlin.math.hypot * - Hold: temporary 2x playback while held * - Two-finger pinch: step video gravity — pinch-out steps Fit -> Fill -> * Stretch, pinch-in steps back, clamped at both ends (iOS parity) - * - Vertical swipe in the left edge zone: brightness adjustment + * - Vertical swipe in the left edge zone: reserved; system brightness remains authoritative * - Vertical swipe in the right edge zone: volume adjustment * - Vertical swipe down in the center: dismiss the player (iOS * MobilePlayerGestureLayer parity — evaluated on release, mostly-vertical @@ -81,8 +79,8 @@ fun PlayerGestureHandler( onPinchVideoGravity: (Boolean) -> Unit = {}, onDismiss: () -> Unit = {}, // Swipe-down-to-dismiss is suppressed until playback is actually established. - // During the initial open the media is still loading, and a downward volume/ - // brightness swipe that drifts inward would otherwise be read as "close the + // During the initial open the media is still loading, and a downward edge + // swipe that drifts inward would otherwise be read as "close the // player" — which then strands the user (Jim, Fold). dismissEnabled: Boolean = true, modifier: Modifier = Modifier, @@ -204,20 +202,21 @@ fun PlayerGestureHandler( ) } .pointerInput(Unit) { - // iOS edgeAndDismissDrag: the start x picks the mode once — - // left 88dp edge = brightness, right 88dp edge = volume, and a - // center drag becomes a dismiss candidate judged on release. + // The start x picks the mode once: the left 88dp edge is + // reserved so Android brightness stays authoritative, the + // right edge controls volume, and the center is a dismiss + // candidate judged on release. var mode = VerticalDragMode.None var totalDrag = Offset.Zero val edgeZonePx = EdgeZoneWidthDp.dp.toPx() detectVerticalDragGestures( onDragStart = { start -> totalDrag = Offset.Zero - mode = when { - start.x < edgeZonePx -> VerticalDragMode.Brightness - start.x > size.width - edgeZonePx -> VerticalDragMode.Volume - else -> VerticalDragMode.DismissCandidate - } + mode = verticalDragMode( + startX = start.x, + width = size.width.toFloat(), + edgeZonePx = edgeZonePx, + ) }, onDragEnd = { if (currentDismissEnabled && mode == VerticalDragMode.DismissCandidate) { @@ -236,8 +235,6 @@ fun PlayerGestureHandler( totalDrag += change.position - change.previousPosition val sensitivity = 0.01f when (mode) { - VerticalDragMode.Brightness -> - adjustBrightness(context, -dragAmount * sensitivity) VerticalDragMode.Volume -> adjustVolume(audioManager, -dragAmount * sensitivity) else -> Unit @@ -305,31 +302,27 @@ private const val SkipFlashHoldMs = 700L private data class SkipFlash(val forward: Boolean, val nonce: Long) -private enum class VerticalDragMode { None, Brightness, Volume, DismissCandidate } +internal enum class VerticalDragMode { None, Volume, DismissCandidate } -/** iOS edge-zone width (88pt) for brightness/volume vertical drags. */ +/** Edge-zone width for the reserved left edge and right-edge volume drag. */ private const val EdgeZoneWidthDp = 88 +internal fun verticalDragMode( + startX: Float, + width: Float, + edgeZonePx: Float, +): VerticalDragMode = when { + startX < edgeZonePx -> VerticalDragMode.None + startX > width - edgeZonePx -> VerticalDragMode.Volume + else -> VerticalDragMode.DismissCandidate +} + /** iOS dismiss threshold: a mostly-vertical downward drag over 140pt. */ private const val DismissDragThresholdDp = 140 private fun pointerDistance(first: Offset, second: Offset): Float = hypot(first.x - second.x, first.y - second.y) -/** - * Adjusts the screen brightness. Values are clamped to [0.01, 1.0]. - * Uses the window's layout params for per-activity brightness control. - */ -private fun adjustBrightness(context: Context, delta: Float) { - val activity = context as? android.app.Activity ?: return - val window: Window = activity.window - val layoutParams = window.attributes - val currentBrightness = if (layoutParams.screenBrightness < 0) 0.5f else layoutParams.screenBrightness - val newBrightness = (currentBrightness + delta).coerceIn(0.01f, 1.0f) - layoutParams.screenBrightness = newBrightness - window.attributes = layoutParams -} - /** * Adjusts the media volume. Delta is normalized, so we scale to the max volume. */ 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..a4ccf0dda 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 @@ -422,7 +422,10 @@ fun PlayerOverlay( // Sleep timer chip — top-right, fades in only while a timer is active. // The chip stays visible regardless of `state.showControls` so users // know a sleep timer is still running even when the controls have - // auto-hidden. + // auto-hidden. When the HUD is visible, move it below the 48dp toolbar + // controls (16dp edge padding + 48dp target + 8dp gap) so it cannot + // cover Cast or playback settings in landscape. + val sleepTimerTopPadding = if (state.showControls && !state.showUpNext) 72.dp else 16.dp AnimatedVisibility( visible = sleepTimerState is SleepTimerState.Active, enter = fadeIn(), @@ -430,7 +433,7 @@ fun PlayerOverlay( modifier = Modifier .align(Alignment.TopEnd) .windowInsetsPadding(WindowInsets.safeDrawing) - .padding(top = 16.dp, end = 16.dp) + .padding(top = sleepTimerTopPadding, end = 16.dp) .zIndex(2f), ) { val active = sleepTimerState as? SleepTimerState.Active 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..6674722c4 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 @@ -484,6 +484,7 @@ fun PlayerScreen( initialAudioTrackIndex = initialAudioTrackIndex, initialSubtitleTrackIndex = initialSubtitleTrackIndex, resumePositionOverride = resumePositionOverride, + routeResumePositionSeconds = resumePositionOverride, // Watch Together's synced anchor must land exactly — don't nudge it back. suppressResumeRewind = !roomId.isNullOrBlank(), ) @@ -997,7 +998,11 @@ fun PlayerScreen( identity = pendingIdentity, selected = selected, snapshotKey = media3TextTrackSnapshotKey(backend.player.currentTracks), - settled = backend.player.playbackState == Player.STATE_READY, + // This composition-side attempt can race Media3's first + // text-track publication. Only onTracksChanged callbacks + // provide settlement evidence; the adapter then requires + // the same non-empty snapshot twice before failing. + settled = false, ) } } else { 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..9c609adf1 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 @@ -39,6 +39,7 @@ import org.prairieserver.prairie.common.player.seek.playerPositionForSource import org.prairieserver.prairie.common.player.seek.sourcePositionForPlayer import org.prairieserver.prairie.common.player.video.VideoPlaybackSessionCoordinator import org.prairieserver.prairie.common.player.video.VideoPlaybackStartRequest +import org.prairieserver.prairie.common.player.video.VideoPlayerRouteArgs import org.prairieserver.prairie.common.player.video.VideoPlayerUiState import org.prairieserver.prairie.common.player.video.canPlayResolvedStreamDirectly import org.prairieserver.prairie.common.player.video.resolvedPlaybackDelivery @@ -232,6 +233,10 @@ class PlayerViewModel( // Last load request, replayed by the "Can't reach server" Retry / Try Anyway. private var lastLoadArgs: LoadArgs? = null + // Route semantics are separate from resolved playback state. In particular, + // a null file/track means automatic selection and must remain null after the + // first successful resolution; recovery reloads must not turn it explicit. + private val routeIntentState = MobilePlayerRouteIntentState() private data class LoadArgs( val contentId: String, @@ -243,6 +248,9 @@ class PlayerViewModel( val suppressResumeRewind: Boolean, ) + internal fun currentExternalRouteTarget(): MobilePlayerRouteTarget? = + mobilePlayerRouteTarget(routeIntentState.current, _uiState.value) + companion object { private const val TAG = "PlayerViewModel" const val SERVER_UNREACHABLE_MESSAGE = @@ -621,9 +629,9 @@ class PlayerViewModel( val subtitleAppearance: StateFlow = playerSettingsStore.effectiveSubtitleAppearanceFlow .stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleAppearance.DEFAULT) /** - * Per-profile audio/subtitle delay in ms. Mirrors iOS phone's `audioSyncMs` / + * Per-device audio/subtitle delay in ms. Mirrors iOS phone's `audioSyncMs` / * `subtitleSyncMs` (`iosApp/Screens/Player/Sheets/PlayerSettingsSheet.swift:265-285`). - * Applied by PrairiePlaybackService via DelayAudioProcessor (audio) and + * Applied by SiloPlaybackService via DelayAudioProcessor (audio) and * OffsetSubtitleParserFactory (subtitle); the settings sheet rows write * directly through the store and the live player picks up the change on * the next flush / parse. @@ -739,6 +747,7 @@ class PlayerViewModel( initialSubtitleTrackIndex = state.selectedSubtitleIndex, resumePositionOverride = position, suppressResumeRewind = true, + preserveRouteIntent = true, ) } } @@ -813,6 +822,11 @@ class PlayerViewModel( initialAudioTrackIndex: Int? = null, initialSubtitleTrackIndex: Int? = null, resumePositionOverride: Double? = null, + // Route provenance is separate from an operational seek/restart + // position. Only PlayerScreen's initial route load supplies this; + // internal auto-advance and recovery positions must not become route + // intent. + routeResumePositionSeconds: Double? = null, // True for Watch Together (the synced anchor must land exactly — no // skip-back nudge). The request's roomId is always null on mobile, so WT // can't be inferred from it the way the TV starter does. @@ -820,19 +834,39 @@ class PlayerViewModel( // Try Anyway escape hatch (issue #33): bypass the pre-play reachability // gate and attempt the server even while it reports unreachable. force: Boolean = false, + // Recovery restarts resolved media in place, but they do not change the + // route-level auto/explicit choices used for deep-link idempotence. + preserveRouteIntent: Boolean = false, ) { + val normalizedPreferredQuality = VideoPlayerRouteArgs.normalizeQuality(preferredQuality) + routeIntentState.beginLoad( + contentId = contentId, + fileId = preferredFileId, + quality = normalizedPreferredQuality, + audioTrackIndex = initialAudioTrackIndex, + subtitleTrackIndex = initialSubtitleTrackIndex, + resumePositionSeconds = VideoPlayerRouteArgs.parseResumePosition( + routeResumePositionSeconds?.toString(), + ), + preserveCurrent = preserveRouteIntent, + ) + val effectivePreferredQuality = routeIntentState.qualityForLoad( + contentId = contentId, + normalizedRequestedQuality = normalizedPreferredQuality, + preserveCurrent = preserveRouteIntent, + ) loadJob?.cancel() val loadOwner = loadOwners.begin( contentId = contentId, preferredFileId = preferredFileId, - preferredQuality = preferredQuality, + preferredQuality = effectivePreferredQuality, ) // Remember the exact request so a "Can't reach server" Retry / Try Anyway // can replay it faithfully (this screen has no other retry entry point). lastLoadArgs = LoadArgs( contentId = contentId, preferredFileId = preferredFileId, - preferredQuality = preferredQuality, + preferredQuality = effectivePreferredQuality, initialAudioTrackIndex = initialAudioTrackIndex, initialSubtitleTrackIndex = initialSubtitleTrackIndex, resumePositionOverride = resumePositionOverride, @@ -895,7 +929,7 @@ class PlayerViewModel( VideoPlaybackStartRequest( contentId = contentId, preferredFileId = preferredFileId, - preferredQualityOverride = preferredQuality, + preferredQualityOverride = effectivePreferredQuality, roomId = null, resumePositionOverride = resumePositionOverride, audioTrackIndex = initialAudioTrackIndex, @@ -990,6 +1024,7 @@ class PlayerViewModel( resumePositionOverride = args.resumePositionOverride, suppressResumeRewind = args.suppressResumeRewind, force = force, + preserveRouteIntent = true, ) } @@ -1230,7 +1265,7 @@ class PlayerViewModel( persistedAudioIndex != selectedAudioOrdinal && persistedAudioIndex in _uiState.value.audioTracks.indices ) { - onSelectAudio(persistedAudioIndex) + selectAudio(persistedAudioIndex, userInitiated = false) } } if (!published) { @@ -2617,6 +2652,14 @@ class PlayerViewModel( subtitleApplying = snapshot.subtitleApplying, ) } + val state = _uiState.value + routeIntentState.applyCommittedTracks( + contentId = state.contentId, + committedAudioServerIndex = snapshot.transition.committed.audioTrackIndex, + committedSubtitleIdentity = snapshot.committedIdentity, + transactionFailed = snapshot.failureMessage != null, + transactionActive = mobileSubtitleTransactions.hasActiveTransaction, + ) snapshot.failureMessage?.let { showVersionSwitchMessage("Couldn't apply subtitles — playback continues unchanged.") } @@ -2707,6 +2750,7 @@ class PlayerViewModel( initialSubtitleTrackIndex = state.selectedSubtitleIndex, resumePositionOverride = state.position, suppressResumeRewind = true, + preserveRouteIntent = true, ) Log.w(TAG, "Subtitle committed-playback adoption failed: $detail") } @@ -2719,6 +2763,11 @@ class PlayerViewModel( .getOrNull(index) ?.let(::mobileSubtitleIdentity) ?: SubtitleIdentity.Off + routeIntentState.beginSubtitleSelection( + contentId = state.contentId, + routeOrdinal = catalogSubtitleRouteOrdinal(state, identity), + identity = identity, + ) mobileSubtitleTransactions.updatePlaybackContext(mobileSubtitleContext(state)) mobileSubtitleTransactions.select(identity) } @@ -2738,9 +2787,18 @@ class PlayerViewModel( } /** Select an audio track (may require server-side switch). */ - fun onSelectAudio(index: Int) { + fun onSelectAudio(index: Int) = selectAudio(index, userInitiated = true) + + private fun selectAudio(index: Int, userInitiated: Boolean) { val state = _uiState.value val serverIndex = selectedServerAudioTrackIndex(index, state.audioTracks) ?: return + if (userInitiated) { + routeIntentState.beginAudioSelection( + contentId = state.contentId, + routeOrdinal = index, + serverIndex = serverIndex, + ) + } mobileSubtitleTransactions.updatePlaybackContext(mobileSubtitleContext(state)) mobileSubtitleTransactions.selectAudio(serverIndex) } @@ -3312,7 +3370,7 @@ class PlayerViewModel( /** * Audio delay setter (ms). Store clamps to ±5000ms — matches iOS phone's - * `audioSyncMs` range. PrairiePlaybackService mirrors the change into + * `audioSyncMs` range. SiloPlaybackService mirrors the change into * DelayAudioProcessor and forces a seekTo(currentPosition) so the new * value takes effect mid-playback. */ @@ -3322,19 +3380,12 @@ class PlayerViewModel( /** * Subtitle delay setter (ms). Store clamps to ±10000ms — matches iOS - * phone's `subtitleSyncMs` range. PrairiePlaybackService mirrors the + * phone's `subtitleSyncMs` range. SiloPlaybackService mirrors the * change into SubtitleOffsetHolder; OffsetSubtitleParserFactory reads * the new offset at every cue parse. */ fun onSetSubtitleDelay(value: Int) { - val contentId = _uiState.value.contentId.takeIf(String::isNotBlank) - viewModelScope.launch { - if (contentId == null) { - playerSettingsStore.setSubtitleSyncMs(value) - } else { - playerSettingsStore.setSubtitleSyncMsFor(contentId, value) - } - } + viewModelScope.launch { playerSettingsStore.setSubtitleSyncMs(value) } } // ---- Sleep timer setters --------------------------------------------------- @@ -3373,6 +3424,11 @@ class PlayerViewModel( val state = _uiState.value val version = state.versions.getOrNull(index) ?: return if (!isRecovery && index == state.selectedVersionIndex) return + if (isRecovery) { + routeIntentState.recoverVersionSelection(state.contentId) + } else { + routeIntentState.beginVersionSelection(state.contentId, version.fileId) + } viewModelScope.launch { sessionLifecycle.stop() loadContent( @@ -3382,6 +3438,7 @@ class PlayerViewModel( initialSubtitleTrackIndex = state.selectedSubtitleIndex, resumePositionOverride = state.position, suppressResumeRewind = true, + preserveRouteIntent = true, ) } } @@ -3466,6 +3523,7 @@ class PlayerViewModel( /** Called when the user exits the player. */ fun onExit() { if (!exitPrepared.compareAndSet(false, true)) return + routeIntentState.clear() resetPlaybackRecoveryState() loadOwners.invalidate() loadJob?.cancel() diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomSyncController.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomSyncController.kt index 820f68e21..3d9b198f1 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomSyncController.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomSyncController.kt @@ -148,8 +148,16 @@ class RoomSyncController( while (isActive) { val state = viewModel.uiState.value val sessionId = state.sessionId + val deliveryKey = deliveryLatch.keyOrNull( + repository.connectionState.value, + sessionId, + ) val now = monotonicMs() - if (sessionId != null && + if (deliveryKey != null && + deliveryLatch.isServerAttached( + deliveryKey, + repository.roomDeliveryEcho.value, + ) && shouldEmitStateReport( now, lastReportMs, @@ -160,7 +168,7 @@ class RoomSyncController( ) { lastReportMs = now repository.stateReport( - sessionId = sessionId, + sessionId = deliveryKey.playbackSessionId, positionSeconds = state.position, isPaused = state.isPaused, ) @@ -186,7 +194,14 @@ class RoomSyncController( if (playbackState != RoomPlaybackState.Waiting || key == null) { return@collectLatest } - while (!deliveryLatch.isAttached(key)) delay(10) + while ( + !deliveryLatch.isServerAttached( + key, + repository.roomDeliveryEcho.value, + ) + ) { + delay(10) + } while (isActive && deliveryLatch.needsReadiness(key, buffering)) { val currentState = viewModel.uiState.value val delivered = if (buffering) { diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/CreateProfileScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/CreateProfileScreen.kt index 3f25968d3..05428b5d0 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/CreateProfileScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/CreateProfileScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -46,9 +47,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.prairieserver.prairie.android.ui.screens.auth.AuthColors import org.prairieserver.prairie.android.ui.screens.auth.AuthErrorBanner -import org.prairieserver.prairie.android.ui.screens.auth.PrairieButton -import org.prairieserver.prairie.android.ui.screens.auth.PrairieTextField -import org.prairieserver.prairie.model.profile.displayProfileQualityPreference +import org.prairieserver.prairie.android.ui.screens.auth.SiloButton +import org.prairieserver.prairie.android.ui.screens.auth.SiloTextField import org.koin.compose.viewmodel.koinViewModel /** @@ -100,9 +100,11 @@ fun CreateProfileScreen( Column( modifier = Modifier - .fillMaxSize() + .weight(1f) + .fillMaxWidth() .verticalScroll(rememberScrollState()) .imePadding() + .navigationBarsPadding() .padding(horizontal = 24.dp), ) { state.error?.let { error -> @@ -128,11 +130,11 @@ fun CreateProfileScreen( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - for (emoji in AvatarOptions.emojis) { + for (avatarRef in AvatarOptions.presets) { AvatarPickerItem( - emoji = emoji, - isSelected = state.selectedAvatar == emoji, - onClick = { viewModel.onAvatarSelected(emoji) }, + avatarRef = avatarRef, + isSelected = state.selectedAvatar == avatarRef, + onClick = { viewModel.onAvatarSelected(avatarRef) }, ) } } @@ -140,7 +142,7 @@ fun CreateProfileScreen( Spacer(modifier = Modifier.height(24.dp)) // -- Name -- - PrairieTextField( + SiloTextField( value = state.name, onValueChange = viewModel::onNameChanged, label = "Profile Name", @@ -178,7 +180,7 @@ fun CreateProfileScreen( if (state.pinEnabled) { Spacer(modifier = Modifier.height(8.dp)) - PrairieTextField( + SiloTextField( value = state.pin, onValueChange = viewModel::onPinChanged, label = "4-Digit PIN", @@ -188,16 +190,6 @@ fun CreateProfileScreen( Spacer(modifier = Modifier.height(24.dp)) - // -- Quality preference -- - DropdownField( - label = "Quality Preference", - selected = displayProfileQualityPreference(state.qualityPreference), - options = QUALITY_OPTIONS, - onSelected = viewModel::onQualitySelected, - ) - - Spacer(modifier = Modifier.height(16.dp)) - // -- Subtitle mode -- DropdownField( label = "Subtitles", @@ -210,7 +202,7 @@ fun CreateProfileScreen( Spacer(modifier = Modifier.height(32.dp)) - PrairieButton( + SiloButton( text = "Create Profile", onClick = viewModel::onCreateClick, isLoading = state.isLoading, @@ -225,7 +217,7 @@ fun CreateProfileScreen( @Composable internal fun SectionHeader(text: String) { - // iOS phone field labels use prairieCaption (12pt regular, secondary). + // iOS phone field labels use siloCaption (12pt regular, secondary). Text( text = text, fontSize = 12.sp, @@ -247,14 +239,14 @@ internal fun SwitchRow( verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { - // iOS phone Toggle title: prairieBody (14pt), onSurface. + // iOS phone Toggle title: siloBody (14pt), onSurface. Text( text = label, fontSize = 14.sp, color = AuthColors.OnBackground, ) if (subtitle != null) { - // iOS phone Toggle subtitle: prairieCaption (12pt), secondary. + // iOS phone Toggle subtitle: siloCaption (12pt), secondary. Text( text = subtitle, fontSize = 12.sp, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/CreateProfileViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/CreateProfileViewModel.kt index eefaf3e77..a0780dcde 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/CreateProfileViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/CreateProfileViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.model.profile.CreateProfileRequest import org.prairieserver.prairie.model.profile.Profile -import org.prairieserver.prairie.model.profile.canonicalProfileQualityPreference import org.prairieserver.prairie.model.profile.hasProfileNamed import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.repository.ProfileRepository @@ -21,7 +20,6 @@ data class CreateProfileUiState( val maxContentRating: String? = null, val pinEnabled: Boolean = false, val pin: String = "", - val qualityPreference: String? = null, val language: String? = null, val subtitleLanguage: String? = null, val subtitleMode: String? = null, @@ -62,8 +60,8 @@ class CreateProfileViewModel( _uiState.update { it.copy(name = value, error = null) } } - fun onAvatarSelected(emoji: String) { - _uiState.update { it.copy(selectedAvatar = emoji) } + fun onAvatarSelected(avatarRef: String) { + _uiState.update { it.copy(selectedAvatar = avatarRef) } } fun onChildToggled(checked: Boolean) { @@ -90,12 +88,6 @@ class CreateProfileViewModel( _uiState.update { it.copy(pin = filtered, error = null) } } - fun onQualitySelected(quality: String) { - _uiState.update { - it.copy(qualityPreference = canonicalProfileQualityPreference(quality)) - } - } - fun onLanguageSelected(language: String?) { _uiState.update { it.copy(language = language) } } @@ -135,7 +127,6 @@ class CreateProfileViewModel( pin = if (current.pinEnabled) current.pin else null, isChild = if (current.isChild) true else null, maxContentRating = current.maxContentRating, - qualityPreference = current.qualityPreference, language = current.language, subtitleLanguage = current.subtitleLanguage, subtitleMode = current.subtitleMode, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/EditProfileScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/EditProfileScreen.kt index d18c5634f..d5677770a 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/EditProfileScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/EditProfileScreen.kt @@ -128,11 +128,11 @@ fun EditProfileScreen( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - for (emoji in AvatarOptions.emojis) { + for (avatarRef in AvatarOptions.presets) { AvatarPickerItem( - emoji = emoji, - isSelected = state.selectedAvatar == emoji, - onClick = { viewModel.onAvatarSelected(emoji) }, + avatarRef = avatarRef, + isSelected = state.selectedAvatar == avatarRef, + onClick = { viewModel.onAvatarSelected(avatarRef) }, ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/EditProfileViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/EditProfileViewModel.kt index d8323415b..04a8c1b19 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/EditProfileViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/EditProfileViewModel.kt @@ -105,8 +105,8 @@ class EditProfileViewModel( _uiState.update { it.copy(name = value, error = null) } } - fun onAvatarSelected(emoji: String) { - _uiState.update { it.copy(selectedAvatar = emoji) } + fun onAvatarSelected(avatarRef: String) { + _uiState.update { it.copy(selectedAvatar = avatarRef) } } fun onChildToggled(checked: Boolean) { diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileAvatar.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileAvatar.kt index 4726aa4a7..8838818bf 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileAvatar.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileAvatar.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -28,34 +27,30 @@ import org.prairieserver.prairie.common.ui.components.rememberProfileServerUrl import org.prairieserver.prairie.common.ui.components.resolveAvatarUrl /** - * Pre-defined avatar options. Each entry is an emoji displayed inside a coloured circle. - * - * The server stores the avatar as a string. It may be an emoji, a DiceBear URL, or - * an uploaded image path. When no avatar is set, initials derived from the profile - * name are shown instead. + * Pre-defined avatar options using the server's supported preset vocabulary. */ object AvatarOptions { - val emojis = listOf( - "\uD83D\uDE00", // grinning face - "\uD83D\uDE0E", // smiling face with sunglasses - "\uD83E\uDD13", // nerd face - "\uD83E\uDD78", // disguised face - "\uD83D\uDC7E", // alien monster - "\uD83D\uDC31", // cat face - "\uD83D\uDC36", // dog face - "\uD83E\uDD8A", // fox - "\uD83E\uDD81", // lion - "\uD83D\uDC3B", // bear - "\uD83D\uDC27", // penguin - "\uD83E\uDD89", // owl - "\uD83C\uDF1F", // glowing star - "\uD83C\uDF08", // rainbow - "\uD83C\uDFA8", // artist palette - "\uD83C\uDFAC", // clapper board - "\uD83C\uDFB5", // musical note - "\uD83D\uDE80", // rocket - "\uD83C\uDF0D", // globe - "\uD83C\uDF53", // strawberry + val presets = listOf( + "preset:dicebear:fun-emoji:cosmic-otter", + "preset:dicebear:fun-emoji:comet-cat", + "preset:dicebear:fun-emoji:star-bear", + "preset:dicebear:fun-emoji:neon-pup", + "preset:dicebear:fun-emoji:orbit-bunny", + "preset:dicebear:fun-emoji:solar-owl", + "preset:dicebear:fun-emoji:nova-gecko", + "preset:dicebear:fun-emoji:pixel-penguin", + "preset:dicebear:fun-emoji:mango-fox", + "preset:dicebear:fun-emoji:bubble-lion", + "preset:dicebear:fun-emoji:marble-panda", + "preset:dicebear:fun-emoji:starlight-tiger", + "preset:dicebear:fun-emoji:candy-dragon", + "preset:dicebear:fun-emoji:ember-parrot", + "preset:dicebear:fun-emoji:mochi-robot", + "preset:dicebear:fun-emoji:twinkle-sprite", + "preset:dicebear:fun-emoji:mint-puffin", + "preset:dicebear:fun-emoji:velvet-panther", + "preset:dicebear:fun-emoji:sunbeam-falcon", + "preset:dicebear:fun-emoji:lunar-meteor", ) /** Deterministic colour for a given avatar string so the same profile always gets the same colour. */ @@ -94,7 +89,7 @@ fun ProfileAvatar( onClick: (() -> Unit)? = null, ) { val displayText = profileAvatarDisplayText(avatar = avatar, name = name) - // iOS ProfileAvatarView uses a single flat prairieSurfaceVariant (#0E0F12). + // iOS ProfileAvatarView uses a single flat siloSurfaceVariant (#0E0F12). val bgColor = Color(0xFF0E0F12) val serverUrl = rememberProfileServerUrl() val resolvedAvatarUrl = remember(avatar, serverUrl) { @@ -150,31 +145,21 @@ fun ProfileAvatar( } /** - * Smaller selectable emoji avatar used in the avatar picker grid on - * create/edit screens. Mirrors iOS phone `EditProfileView`'s emoji grid: - * 40dp rounded-rect cell (radius 8), emoji at 28pt, selection shown by a - * tinted background (prairiePrimary at 30%). + * Smaller selectable server-backed avatar used in the create/edit picker grid. */ @Composable fun AvatarPickerItem( - emoji: String, + avatarRef: String, isSelected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, ) { - Box( - modifier = modifier - .size(40.dp) - .clip(RoundedCornerShape(8.dp)) - .background( - if (isSelected) AuthColors.Primary.copy(alpha = 0.3f) else Color.Transparent, - ) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center, - ) { - Text( - text = emoji, - fontSize = 28.sp, - ) - } + ProfileAvatar( + avatar = avatarRef, + name = "Profile avatar", + modifier = modifier, + size = 40.dp, + selected = isSelected, + onClick = onClick, + ) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/PlaybackSettings.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/PlaybackSettings.kt index d0395e2c0..6e10549b0 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/PlaybackSettings.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/PlaybackSettings.kt @@ -15,9 +15,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import org.prairieserver.prairie.model.settings.LanguageOptions +import org.prairieserver.prairie.model.settings.QualityPresets +import org.prairieserver.prairie.model.settings.SettingKeys -private val qualityOptions = listOf("Auto", "Original", "4K", "1080p", "720p", "480p") -private val languageOptions = listOf("Default", "English", "Spanish", "French", "German", "Japanese", "Korean", "Chinese", "Portuguese", "Italian", "Russian") +// Quality is two settings behind one picker: playback.preferred_quality (a +// resolution cap) and playback.max_bitrate_kbps (a bandwidth cap, null = +// uncapped). The preset table is shared with the TV app and mirrors the web +// client's, so the same choice reads back with the same label everywhere. // Discrete choices for the two behavior settings (0 = off). Dropdown idiom // matches the rest of this section; the label↔value maps below convert. @@ -40,8 +45,10 @@ private fun nextUpPromptLabel(seconds: Int): String = when { */ @Composable fun PlaybackSettings( - defaultQuality: String, + qualityResolution: String, + maxBitrateKbps: Int?, audioLanguage: String, + audioLanguageSuggestions: List = emptyList(), autoSkipIntro: Boolean, autoSkipCredits: Boolean, pictureInPictureEnabled: Boolean, @@ -51,7 +58,8 @@ fun PlaybackSettings( nextUpPromptSeconds: Int, resumeRewindSeconds: Int, passOutThreshold: Int, - onQualityChanged: (String) -> Unit, + /** Receives a [QualityPresets] preset id. */ + onQualityPresetSelected: (String) -> Unit, onAudioLanguageChanged: (String) -> Unit, onAutoSkipIntroChanged: (Boolean) -> Unit, onAutoSkipCreditsChanged: (Boolean) -> Unit, @@ -65,21 +73,36 @@ fun PlaybackSettings( onResetPlaybackOverrides: () -> Unit, modifier: Modifier = Modifier, ) { + val audioLanguageOptions = remember(audioLanguage, audioLanguageSuggestions) { + LanguageOptions.options( + key = SettingKeys.PLAYBACK_AUDIO_LANGUAGE, + currentValue = audioLanguage, + runtimeValues = audioLanguageSuggestions, + ) + } SettingsSectionCard(modifier = modifier) { SettingsSectionHeader("Playback") + // A pair no preset covers (set through the API, or left by a legacy + // compound value) still gets a truthful label rather than a picker + // silently showing the wrong entry. SettingsDropdownRow( label = "Default Quality", - value = defaultQuality, - options = qualityOptions, - onOptionSelected = onQualityChanged, + value = QualityPresets.describe(qualityResolution, maxBitrateKbps), + options = QualityPresets.ALL.map { it.label }, + onOptionSelected = { label -> + QualityPresets.ALL.firstOrNull { it.label == label } + ?.let { onQualityPresetSelected(it.id) } + }, ) SettingsDropdownRow( label = "Audio Language", - value = audioLanguage, - options = languageOptions, - onOptionSelected = onAudioLanguageChanged, + value = LanguageOptions.label(audioLanguage, SettingKeys.PLAYBACK_AUDIO_LANGUAGE), + options = audioLanguageOptions.map { it.second }, + onOptionSelected = { label -> + onAudioLanguageChanged(LanguageOptions.wireValue(label, audioLanguageOptions)) + }, ) SettingsSwitchRow( diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsScreen.kt index 91dea9b36..f28701117 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsScreen.kt @@ -191,10 +191,18 @@ fun SettingsScreen( } } + if (state.settingsAvailability == + org.prairieserver.prairie.domain.settings.ProfileSettingsController.Availability.SERVER_UPGRADE_REQUIRED + ) { + item { SettingsUpgradeRequiredNotice() } + } + item { PlaybackSettings( - defaultQuality = state.defaultQuality, + qualityResolution = state.qualityResolution, + maxBitrateKbps = state.maxBitrateKbps, audioLanguage = state.audioLanguage, + audioLanguageSuggestions = state.audioLanguageSuggestions, autoSkipIntro = state.autoSkipIntro, autoSkipCredits = state.autoSkipCredits, pictureInPictureEnabled = state.pictureInPictureEnabled, @@ -204,7 +212,7 @@ fun SettingsScreen( nextUpPromptSeconds = state.nextUpPromptSeconds, resumeRewindSeconds = state.resumeRewindSeconds, passOutThreshold = state.passOutThreshold, - onQualityChanged = viewModel::setDefaultQuality, + onQualityPresetSelected = viewModel::setQualityPreset, onAudioLanguageChanged = viewModel::setAudioLanguage, onAutoSkipIntroChanged = viewModel::setAutoSkipIntro, onAutoSkipCreditsChanged = viewModel::setAutoSkipCredits, @@ -224,6 +232,7 @@ fun SettingsScreen( val metadataAiStatus by metadataAiStore.status.collectAsState() SubtitleSettings( subtitleLanguage = state.subtitleLanguage, + subtitleLanguageSuggestions = state.subtitleLanguageSuggestions, subtitleMode = state.subtitleMode, showForcedSubtitles = state.showForcedSubtitles, onLanguageChanged = viewModel::setSubtitleLanguage, @@ -235,6 +244,7 @@ fun SettingsScreen( metadataLanguageEnabled = metadataAiStatus.enabled && metadataAiStatus.onView != MetadataAiOnView.Off, metadataLanguage = state.metadataLanguage, + metadataLanguageSuggestions = state.metadataLanguageSuggestions, onMetadataLanguageChanged = viewModel::setMetadataLanguage, ) } @@ -412,6 +422,37 @@ fun SettingsScreen( } } +/** + * Shown when the connected server predates the canonical settings API. + * + * The failure mode this replaces was an empty (or silently non-saving) + * settings screen: the profile preferences resolve to nothing, so the rows + * render defaults and an edit goes nowhere with no explanation. Saying so is + * the whole point — playback keeps working from the device's local defaults, + * only the profile-wide preferences are unavailable. + */ +@Composable +fun SettingsUpgradeRequiredNotice(modifier: Modifier = Modifier) { + SettingsSectionCard(modifier = modifier) { + SettingsSectionHeader(title = "Server Update Needed") + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 11.dp)) { + Text( + text = "This server is too old for profile settings", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "Subtitle and metadata preferences are stored by the server, and this one " + + "does not support them yet. Playback still works using this device's settings. " + + "Ask whoever runs the server to update it.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + // --- iOS system-color badge palette (maps SwiftUI .blue/.pink/etc.) --- val SettingsBadgeBlue = Color(0xFF0A84FF) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModel.kt index 8f62bd98f..60ca92699 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModel.kt @@ -5,19 +5,18 @@ import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.common.settings.LibraryPlaybackPrefsStore import org.prairieserver.prairie.common.settings.OverlayPrefsStore import org.prairieserver.prairie.common.settings.PlayerSettingsStore +import org.prairieserver.prairie.domain.settings.ProfileSettingsController import org.prairieserver.prairie.model.admin.shouldShowClientAdminSurface import org.prairieserver.prairie.model.auth.AuthSession import org.prairieserver.prairie.model.auth.User import org.prairieserver.prairie.model.auth.isActingAdmin import org.prairieserver.prairie.model.download.DownloadQuality import org.prairieserver.prairie.model.notifications.NotificationPreferencesUpdate -import org.prairieserver.prairie.model.profile.UpdateProfileRequest +import org.prairieserver.prairie.model.settings.QualityPresets import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.repository.AuthRepository import org.prairieserver.prairie.repository.NotificationsRepository import org.prairieserver.prairie.repository.ProfileRepository -import org.prairieserver.prairie.update.AppUpdateChecker -import org.prairieserver.prairie.update.AppUpdateStatus import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -28,12 +27,18 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** - * Subtitle display mode. + * Subtitle display mode. [wire] is the `playback.subtitle_mode` enum member + * the settings contract declares — the labels are display only. */ -enum class SubtitleMode(val label: String) { - OFF("Off"), - AUTO("Auto"), - ALWAYS("Always"), +enum class SubtitleMode(val label: String, val wire: String) { + OFF("Off", "off"), + AUTO("Auto", "auto"), + ALWAYS("Always", "always"); + + companion object { + fun fromWire(value: String?): SubtitleMode = + entries.firstOrNull { it.wire == value?.lowercase() } ?: AUTO + } } data class SettingsUiState( @@ -48,9 +53,24 @@ data class SettingsUiState( // Client admin is hidden for now even when the server would accept acting-admin. val isAdminVisible: Boolean = false, + // Whether this server serves the canonical settings API. When it reports + // SERVER_UPGRADE_REQUIRED the screen explains that instead of rendering + // rows whose edits would silently go nowhere; playback keeps working from + // the local defaults either way. + val settingsAvailability: ProfileSettingsController.Availability = + ProfileSettingsController.Availability.UNKNOWN, + // Playback - val defaultQuality: String = "Auto", - val audioLanguage: String = "Default", + // The quality picker composes playback.preferred_quality (a resolution + // cap) and playback.max_bitrate_kbps (a bandwidth cap; null = uncapped) + // into one list. The compound legacy spellings are dead and never written. + val qualityResolution: String = QualityPresets.RESOLUTION_AUTO, + val maxBitrateKbps: Int? = null, + /** True when policy capped the resolution below the profile's choice. */ + val qualityConstrained: Boolean = false, + // BCP 47 tag, "" = no preference. The picker converts to and from labels. + val audioLanguage: String = "", + val audioLanguageSuggestions: List = emptyList(), val autoSkipIntro: Boolean = false, val autoSkipCredits: Boolean = false, val pictureInPictureEnabled: Boolean = true, @@ -75,10 +95,13 @@ data class SettingsUiState( val defaultDownloadQuality: String = DownloadQuality.Original.label, // Subtitles - val subtitleLanguage: String = "Off", + // BCP 47 tag, "" = off. The picker converts to and from labels. + val subtitleLanguage: String = "", + val subtitleLanguageSuggestions: List = emptyList(), // Metadata AI: preferred description/metadata language. // ISO 639-1 code; "" = inherit library metadata language. val metadataLanguage: String = "", + val metadataLanguageSuggestions: List = emptyList(), val subtitleMode: SubtitleMode = SubtitleMode.AUTO, val showForcedSubtitles: Boolean = true, @@ -90,10 +113,6 @@ data class SettingsUiState( val notifyWatchlist: Boolean = true, val notifyContinueWatching: Boolean = true, val notifyNextUp: Boolean = true, - - // App version / update status (GitHub Releases for this client family). - val appVersionName: String = "", - val appUpdateStatus: AppUpdateStatus = AppUpdateStatus.Checking, ) class SettingsViewModel( @@ -103,13 +122,10 @@ class SettingsViewModel( private val libraryPlaybackPrefsStore: LibraryPlaybackPrefsStore, private val overlayPrefsStore: OverlayPrefsStore, private val notificationsRepository: NotificationsRepository, - private val appUpdateChecker: AppUpdateChecker, - private val appVersionName: String, + private val profileSettings: ProfileSettingsController, ) : ViewModel() { - private val _uiState = MutableStateFlow( - SettingsUiState(appVersionName = appVersionName), - ) + private val _uiState = MutableStateFlow(SettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() init { @@ -117,20 +133,6 @@ class SettingsViewModel( observePlayerSettings() observePlaybackBehaviorSettings() observeNotifications() - checkForAppUpdate() - } - - private fun checkForAppUpdate() { - viewModelScope.launch { - _uiState.update { - it.copy( - appVersionName = appVersionName, - appUpdateStatus = AppUpdateStatus.Checking, - ) - } - val status = appUpdateChecker.check(appVersionName) - _uiState.update { it.copy(appUpdateStatus = status) } - } } private fun loadUserInfo() { @@ -150,15 +152,14 @@ class SettingsViewModel( playerSettingsStore.refreshFromServer() + // The profile still supplies identity (name, role) for the admin + // gate; its preference columns no longer feed this screen — those + // are resolved canonically below. when (val profileResult = profileRepository.getActiveProfileResult()) { is ApiResult.Success -> { val profile = profileResult.data _uiState.update { it.copy( - subtitleLanguage = profile.subtitleLanguage?.ifBlank { "Off" } ?: "Off", - metadataLanguage = profile.preferredMetadataLanguage.orEmpty(), - subtitleMode = subtitleModeFromServer(profile.subtitleMode), - showForcedSubtitles = profile.showForcedSubtitles ?: true, isAdminVisible = shouldShowClientAdminSurface(isActingAdmin(it.user, profile)), ) } @@ -171,11 +172,44 @@ class SettingsViewModel( } } } + + loadProfileSettings() + } + } + + /** + * Resolves the profile-scoped preferences through the canonical settings + * API, and records whether this server speaks it at all. + * + * On [Availability.SERVER_UPGRADE_REQUIRED] the values are left as they + * are and the screen explains the situation — rendering the rows anyway + * would offer edits that go nowhere. Playback is unaffected: it runs from + * the device-scoped store, which has its own defaults. + */ + fun loadProfileSettings() { + viewModelScope.launch { + val result = profileSettings.load() + _uiState.update { state -> + val snapshot = result.snapshot ?: return@update state.copy( + settingsAvailability = result.availability, + ) + state.copy( + settingsAvailability = result.availability, + subtitleLanguage = snapshot.subtitleLanguage, + subtitleMode = SubtitleMode.fromWire(snapshot.subtitleMode), + showForcedSubtitles = snapshot.showForcedSubtitles, + metadataLanguage = snapshot.metadataLanguage, + audioLanguageSuggestions = snapshot.audioLanguageSuggestions, + subtitleLanguageSuggestions = snapshot.subtitleLanguageSuggestions, + metadataLanguageSuggestions = snapshot.metadataLanguageSuggestions, + ) + } } } private data class PlayerSettingsSnapshot( val quality: String, + val maxBitrateKbps: Int?, val audioLanguage: String, val autoSkipIntro: Boolean, val autoSkipCredits: Boolean, @@ -184,6 +218,7 @@ class SettingsViewModel( private fun observePlayerSettings() { combine( playerSettingsStore.preferredQualityFlow, + playerSettingsStore.maxBitrateKbpsFlow, playerSettingsStore.audioLanguageFlow, playerSettingsStore.autoSkipIntroFlow, playerSettingsStore.autoSkipCreditsFlow, @@ -191,8 +226,9 @@ class SettingsViewModel( ).onEach { snap -> _uiState.update { it.copy( - defaultQuality = qualityLabel(snap.quality), - audioLanguage = audioLanguageLabel(snap.audioLanguage), + qualityResolution = snap.quality, + maxBitrateKbps = snap.maxBitrateKbps, + audioLanguage = snap.audioLanguage, autoSkipIntro = snap.autoSkipIntro, autoSkipCredits = snap.autoSkipCredits, ) @@ -391,15 +427,21 @@ class SettingsViewModel( // -- Playback -- - fun setDefaultQuality(quality: String) { + /** + * Applies one quality preset — the two axes it decomposes into. The + * compound legacy spellings ("1080p-high") are never written. + */ + fun setQualityPreset(presetId: String) { + val preset = QualityPresets.byId(presetId) ?: return viewModelScope.launch { - playerSettingsStore.setPreferredQuality(qualityWireValue(quality)) + playerSettingsStore.setQuality(preset.resolution, preset.bitrateKbps) } } + /** [language] is a BCP 47 tag, or "" for no preference. */ fun setAudioLanguage(language: String) { viewModelScope.launch { - playerSettingsStore.setAudioLanguage(audioLanguageWireValue(language)) + playerSettingsStore.setAudioLanguage(language) } } @@ -432,7 +474,13 @@ class SettingsViewModel( } fun setSubtitleAppearance(value: org.prairieserver.prairie.model.settings.SubtitleAppearance) { - viewModelScope.launch { playerSettingsStore.setSubtitleAppearance(value) } + viewModelScope.launch { + playerSettingsStore.setSubtitleAppearance(value) + // The granular subtitle.* fields are client-local — the contract + // carries appearance as one object — so a per-field edit only + // reaches the server once projected into the composite. + playerSettingsStore.flushProjectedSubtitleAppearance() + } } fun resetPlaybackOverrides() { @@ -446,84 +494,120 @@ class SettingsViewModel( // -- Subtitles -- + // These four are profile-scoped canonical settings. They used to ride + // named columns on PUT /profiles/{id}; each now writes exactly the one key + // it changes at scope=profile, so a failed write cannot also revert the + // other three (which sending the whole triple every time did). + // + // Each applies optimistically and rolls back only if the state still shows + // the value it wrote — a newer edit landing during the request wins. + fun setMetadataLanguage(code: String) { + val previous = _uiState.value.metadataLanguage _uiState.update { it.copy(metadataLanguage = code) } viewModelScope.launch { - profileRepository.updateActiveProfile( - UpdateProfileRequest( - preferredMetadataLanguage = code.ifBlank { null }, - ) - ) + val result = profileSettings.setMetadataLanguage(code) + if (!result.succeeded) { + _uiState.update { + if (it.metadataLanguage == code) it.copy(metadataLanguage = previous) else it + } + } else { + applyResolved(result.snapshot, edited = code) { it.metadataLanguage } + } } } + /** [language] is a BCP 47 tag, or "" for off. */ fun setSubtitleLanguage(language: String) { + val previous = _uiState.value.subtitleLanguage _uiState.update { it.copy(subtitleLanguage = language) } - persistProfileSubtitleSettings() + viewModelScope.launch { + val result = profileSettings.setSubtitleLanguage(language) + if (!result.succeeded) { + _uiState.update { + if (it.subtitleLanguage == language) it.copy(subtitleLanguage = previous) else it + } + } else { + applyResolved(result.snapshot, edited = language) { it.subtitleLanguage } + } + } } fun setSubtitleMode(mode: SubtitleMode) { + val previous = _uiState.value.subtitleMode _uiState.update { it.copy(subtitleMode = mode) } - persistProfileSubtitleSettings() + viewModelScope.launch { + val result = profileSettings.setSubtitleMode(mode.wire) + if (!result.succeeded) { + _uiState.update { + if (it.subtitleMode == mode) it.copy(subtitleMode = previous) else it + } + } else { + applyResolved(result.snapshot, edited = mode.wire) { it.subtitleMode } + } + } } fun setShowForcedSubtitles(enabled: Boolean) { + val previous = _uiState.value.showForcedSubtitles _uiState.update { it.copy(showForcedSubtitles = enabled) } - persistProfileSubtitleSettings() - } - - private fun persistProfileSubtitleSettings() { - val state = _uiState.value viewModelScope.launch { - profileRepository.updateActiveProfile( - UpdateProfileRequest( - subtitleLanguage = state.subtitleLanguage.takeUnless { it == "Off" }, - subtitleMode = state.subtitleMode.toServerValue(), - showForcedSubtitles = state.showForcedSubtitles, - ) - ) + val result = profileSettings.setShowForcedSubtitles(enabled) + if (!result.succeeded) { + _uiState.update { + if (it.showForcedSubtitles == enabled) it.copy(showForcedSubtitles = previous) else it + } + } else { + applyResolved(result.snapshot, edited = enabled.toString()) { + it.showForcedSubtitles.toString() + } + } } } - private fun qualityLabel(value: String): String = - when (value.lowercase()) { - "auto" -> "Auto" - "original" -> "Original" - "2160p", "4k" -> "4K" - else -> value.uppercase() + /** + * Replaces the optimistic values with what the server actually resolves. + * + * A successful PUT stores the authored value; it does not make it + * effective. Policy can narrow it, and a device-scoped row for the same key + * outranks the profile row these setters write — so the screen would + * otherwise show a preference playback is not using. Skipped when a newer + * edit for the *same* field landed while the round trip was in flight + * ([edited] no longer matches [fieldOf]), which the optimistic rollback + * above guards the same way. + */ + private fun applyResolved( + snapshot: ProfileSettingsController.Snapshot?, + edited: String, + fieldOf: (ProfileSettingsController.Snapshot) -> String, + ) { + if (snapshot == null) return + if (fieldOf(snapshot) == edited) { + _uiState.update { + it.copy( + audioLanguageSuggestions = snapshot.audioLanguageSuggestions, + subtitleLanguageSuggestions = snapshot.subtitleLanguageSuggestions, + metadataLanguageSuggestions = snapshot.metadataLanguageSuggestions, + ) + } + return } - - private fun qualityWireValue(value: String): String = - when (value) { - "Auto" -> "auto" - "Original" -> "original" - "4K" -> "2160p" - else -> value.lowercase() + _uiState.update { state -> + state.copy( + subtitleLanguage = snapshot.subtitleLanguage, + subtitleMode = SubtitleMode.fromWire(snapshot.subtitleMode), + showForcedSubtitles = snapshot.showForcedSubtitles, + metadataLanguage = snapshot.metadataLanguage, + audioLanguageSuggestions = snapshot.audioLanguageSuggestions, + subtitleLanguageSuggestions = snapshot.subtitleLanguageSuggestions, + metadataLanguageSuggestions = snapshot.metadataLanguageSuggestions, + ) } + } private fun downloadQualityLabel(value: String): String = DownloadQuality.fromWire(value).label private fun downloadQualityWireValue(value: String): String = DownloadQuality.entries.firstOrNull { it.label == value }?.wire ?: DownloadQuality.Original.wire - - private fun audioLanguageLabel(value: String): String = - value.ifBlank { "Default" } - - private fun audioLanguageWireValue(value: String): String = - value.takeUnless { it == "Default" }.orEmpty() - - private fun subtitleModeFromServer(value: String?): SubtitleMode = - when (value?.lowercase()) { - "off" -> SubtitleMode.OFF - "always" -> SubtitleMode.ALWAYS - else -> SubtitleMode.AUTO - } - - private fun SubtitleMode.toServerValue(): String = - when (this) { - SubtitleMode.OFF -> "off" - SubtitleMode.AUTO -> "auto" - SubtitleMode.ALWAYS -> "always" - } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SubtitleSettings.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SubtitleSettings.kt index 89741ea50..635f1887c 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SubtitleSettings.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SubtitleSettings.kt @@ -1,27 +1,12 @@ package org.prairieserver.prairie.android.ui.screens.settings import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ClosedCaption import androidx.compose.ui.Modifier - -private val subtitleLanguageOptions = listOf("Off", "English", "Spanish", "French", "German", "Japanese", "Korean", "Chinese", "Portuguese", "Italian", "Russian") - -// Metadata language stores ISO 639-1 codes (server contract; "" = inherit) — -// display labels, persist codes. Kept in lockstep with the TV list. -private val metadataLanguageOptions = listOf( - "" to "Off", - "en" to "English", - "es" to "Spanish", - "fr" to "French", - "de" to "German", - "ja" to "Japanese", - "ko" to "Korean", - "zh" to "Chinese", - "pt" to "Portuguese", - "it" to "Italian", - "ru" to "Russian", -) +import org.prairieserver.prairie.model.settings.LanguageOptions +import org.prairieserver.prairie.model.settings.SettingKeys /** * Subtitle settings section with language, display mode, and forced subtitles toggle. @@ -29,6 +14,7 @@ private val metadataLanguageOptions = listOf( @Composable fun SubtitleSettings( subtitleLanguage: String, + subtitleLanguageSuggestions: List = emptyList(), subtitleMode: SubtitleMode, showForcedSubtitles: Boolean, onLanguageChanged: (String) -> Unit, @@ -40,17 +26,34 @@ fun SubtitleSettings( modifier: Modifier = Modifier, // Metadata AI description translation (server-gated; row hidden when off). metadataLanguageEnabled: Boolean = false, - metadataLanguage: String = "Off", + metadataLanguage: String = "", + metadataLanguageSuggestions: List = emptyList(), onMetadataLanguageChanged: (String) -> Unit = {}, ) { + val subtitleLanguageOptions = remember(subtitleLanguage, subtitleLanguageSuggestions) { + LanguageOptions.options( + key = SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, + currentValue = subtitleLanguage, + runtimeValues = subtitleLanguageSuggestions, + ) + } + val metadataLanguageOptions = remember(metadataLanguage, metadataLanguageSuggestions) { + LanguageOptions.options( + key = SettingKeys.CATALOG_METADATA_LANGUAGE, + currentValue = metadataLanguage, + runtimeValues = metadataLanguageSuggestions, + ) + } SettingsSectionCard(modifier = modifier) { SettingsSectionHeader("Subtitles") SettingsDropdownRow( label = "Subtitle Language", - value = subtitleLanguage, - options = subtitleLanguageOptions, - onOptionSelected = onLanguageChanged, + value = LanguageOptions.label(subtitleLanguage, SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE), + options = subtitleLanguageOptions.map { it.second }, + onOptionSelected = { label -> + onLanguageChanged(LanguageOptions.wireValue(label, subtitleLanguageOptions)) + }, ) SettingsDropdownRow( @@ -85,15 +88,12 @@ fun SubtitleSettings( } if (metadataLanguageEnabled) { - val selectedLabel = metadataLanguageOptions.firstOrNull { it.first == metadataLanguage }?.second ?: "Off" SettingsDropdownRow( label = "Metadata Language", - value = selectedLabel, + value = LanguageOptions.label(metadataLanguage, SettingKeys.CATALOG_METADATA_LANGUAGE), options = metadataLanguageOptions.map { it.second }, onOptionSelected = { label -> - metadataLanguageOptions.firstOrNull { it.second == label }?.let { - onMetadataLanguageChanged(it.first) - } + onMetadataLanguageChanged(LanguageOptions.wireValue(label, metadataLanguageOptions)) }, ) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntrySheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntrySheet.kt index 9ca74edeb..b857ec5d9 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntrySheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntrySheet.kt @@ -30,16 +30,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp -import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode import org.prairieserver.prairie.watchtogether.canDismissRoomEntry import org.koin.compose.viewmodel.koinViewModel -import androidx.compose.material.icons.Icons -import androidx.compose.material3.Icon -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Link -import androidx.compose.material.icons.automirrored.filled.ArrowBack /** * Watch Together entry sheet, opened from the item-detail overflow. @@ -101,20 +93,10 @@ fun WatchTogetherEntrySheet( onClick = { viewModel.host(contentId, fileId) }, enabled = !state.busy, modifier = Modifier.fillMaxWidth(), - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(if (state.busy) "Creating…" else "Host a room") - } + ) { Text(if (state.busy) "Creating…" else "Host a room") } OutlinedButton( - onClick = { - viewModel.host(contentId, fileId, RoomSelectionMode.Vote) - }, + onClick = { viewModel.hostEmptyVoteRoom() }, enabled = !state.busy, modifier = Modifier.fillMaxWidth(), ) { Text("Host a vote room") } @@ -123,15 +105,7 @@ fun WatchTogetherEntrySheet( onClick = { viewModel.clearError(); showJoin = true }, enabled = !state.busy, modifier = Modifier.fillMaxWidth(), - ) { - Icon( - imageVector = Icons.Default.Link, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Join by code") - } + ) { Text("Join by code") } } else { OutlinedTextField( value = code, @@ -146,31 +120,14 @@ fun WatchTogetherEntrySheet( enabled = !state.busy && code.length >= 4, modifier = Modifier.fillMaxWidth(), ) { - if (state.busy) { - CircularProgressIndicator(modifier = Modifier.height(18.dp)) - } else { - Icon( - imageVector = Icons.Default.Link, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Join") - } + if (state.busy) CircularProgressIndicator(modifier = Modifier.height(18.dp)) + else Text("Join") } OutlinedButton( onClick = { viewModel.clearError(); showJoin = false }, enabled = !state.busy, modifier = Modifier.fillMaxWidth(), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Back") - } + ) { Text("Back") } } } Spacer(Modifier.height(24.dp)) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModel.kt index b59565f84..06b9e5ea9 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModel.kt @@ -5,16 +5,21 @@ import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.android.ui.navigation.Route import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest -import org.prairieserver.prairie.model.watchtogether.MemberRole import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode import org.prairieserver.prairie.model.watchtogether.RoomSnapshot import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.errorMessage -import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryTarget +import org.prairieserver.prairie.watchtogether.resumableWatchTogetherRoom +import org.prairieserver.prairie.watchtogether.watchTogetherEntryTarget import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -26,16 +31,13 @@ import kotlinx.coroutines.launch * Kept top-level + pure so it is unit-testable without Compose or the repo. */ fun watchTogetherDestination(room: RoomSnapshot): String = - if (!room.selectedContentId.isNullOrBlank() && - !(room.selfRole == MemberRole.Host && room.memberCount <= 1) - ) { - Route.Player( - contentId = room.selectedContentId!!, + when (watchTogetherEntryTarget(room)) { + WatchTogetherEntryTarget.Player -> Route.Player( + contentId = requireNotNull(room.selectedContentId), fileId = room.selectedFileId, roomId = room.roomId, ).route - } else { - Route.WatchTogetherLobby(roomId = room.roomId).route + WatchTogetherEntryTarget.Lobby -> Route.WatchTogetherLobby(roomId = room.roomId).route } /** @@ -43,13 +45,13 @@ fun watchTogetherDestination(room: RoomSnapshot): String = * the room selection) or joins an existing room by invite code, then resolves a * navigation [UiState.destination] the sheet observes. * - * The repository stores the room JWT internally on create/join and reads the + * The gateway stores the room JWT internally on create/join and reads the * active roomId from its own snapshot, so [setSelection] takes only the request. * Create/join/selection all return a `{room, room_access_token}` [RoomResponse] * wrapper; the snapshot lives at `.data.room`. */ class WatchTogetherEntryViewModel( - private val repository: WatchTogetherRepository, + private val gateway: WatchTogetherEntryGateway, ) : ViewModel() { data class UiState( @@ -61,6 +63,13 @@ class WatchTogetherEntryViewModel( private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow = _uiState.asStateFlow() + val currentRoom: StateFlow = gateway.roomSnapshot + .map(::resumableWatchTogetherRoom) + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + resumableWatchTogetherRoom(gateway.roomSnapshot.value), + ) /** Host flow: create a room with this title pre-selected as the room selection. */ fun host( @@ -68,22 +77,22 @@ class WatchTogetherEntryViewModel( fileId: Int?, selectionMode: RoomSelectionMode = RoomSelectionMode.HostPick, ) { + if (selectionMode == RoomSelectionMode.Vote) { + hostEmptyVoteRoom() + return + } if (_uiState.value.busy) return _uiState.update { it.copy(busy = true, error = null) } viewModelScope.launch { when ( - val created = repository.createRoom( + val created = gateway.createRoom( CreateRoomRequest(selectionMode = selectionMode.wire), ) ) { is ApiResult.Success -> { - if (selectionMode == RoomSelectionMode.Vote) { - finish(created.data.room) - return@launch - } // Set this title as the room selection so everyone lands on it. when ( - val sel = repository.setSelection( + val sel = gateway.setSelection( SetSelectionRequest(contentId = contentId, fileId = fileId), ) ) { @@ -98,13 +107,41 @@ class WatchTogetherEntryViewModel( } } + /** Host flow for the menu action: create an empty vote room, then enter its lobby. */ + fun hostEmptyVoteRoom() { + if (_uiState.value.busy) return + _uiState.update { it.copy(busy = true, error = null) } + viewModelScope.launch { + when ( + val created = gateway.createRoom( + CreateRoomRequest(selectionMode = RoomSelectionMode.Vote.wire), + ) + ) { + is ApiResult.Success -> finish(created.data.room) + is ApiResult.Error, is ApiResult.NetworkError -> + fail(created.errorMessage("Failed to create room")) + } + } + } + + /** Resume the valid room snapshot already held by the process session. */ + fun resumeCurrentRoom() { + if (_uiState.value.busy) return + val room = resumableWatchTogetherRoom(gateway.roomSnapshot.value) + if (room == null) { + fail("Current room is no longer available") + } else { + finish(room) + } + } + /** Join flow: resolve an invite code; route to player if a selection exists, else lobby. */ fun joinByCode(code: String) { val trimmed = code.trim() if (_uiState.value.busy || trimmed.isBlank()) return _uiState.update { it.copy(busy = true, error = null) } viewModelScope.launch { - when (val joined = repository.joinRoom(JoinRoomRequest(code = trimmed))) { + when (val joined = gateway.joinRoom(JoinRoomRequest(code = trimmed))) { is ApiResult.Success -> finish(joined.data.room) is ApiResult.Error, is ApiResult.NetworkError -> fail(joined.errorMessage("Could not join — check the code")) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt index a1591e521..48cc79a48 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt @@ -1,6 +1,7 @@ package org.prairieserver.prairie.android.ui.screens.watchtogether import android.content.Intent +import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,7 +13,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ExitToApp +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.HowToVote import androidx.compose.material.icons.filled.Share import androidx.compose.material3.Button @@ -77,6 +78,14 @@ fun WatchTogetherLobbyScreen( if (closedReason != null) onBack() } + LaunchedEffect(viewModel) { + viewModel.errors.collect { message -> + if (message.isNotBlank()) { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } + } + // Role drives only the cosmetic header label; mutating controls gate on the // server's per-recipient management capability so a demoted/grace-period host // (selfRole still "host" but management revoked) doesn't see dead buttons. @@ -88,11 +97,22 @@ fun WatchTogetherLobbyScreen( TopAppBar( title = { Text("Watch Together") }, navigationIcon = { - IconButton(onClick = { viewModel.leave(); onBack() }) { - Icon(Icons.AutoMirrored.Filled.ExitToApp, contentDescription = "Leave room") + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back to browse", + ) } }, actions = { + TextButton( + onClick = { + viewModel.leave() + onBack() + }, + ) { + Text("Leave room") + } if (canManage) { TextButton(onClick = { viewModel.closeRoom() }) { Text("Close") } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyViewModel.kt index d8ff5ae21..0fe7ebeb1 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyViewModel.kt @@ -8,6 +8,8 @@ import org.prairieserver.prairie.model.watchtogether.MemberRole import org.prairieserver.prairie.model.watchtogether.RoomPhase import org.prairieserver.prairie.model.watchtogether.RoomSnapshot import org.prairieserver.prairie.model.watchtogether.Suggestion +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.errorMessage import org.prairieserver.prairie.repository.WatchTogetherRepository import org.prairieserver.prairie.watchtogether.RoomSession import kotlinx.coroutines.flow.SharingStarted @@ -62,17 +64,36 @@ class WatchTogetherLobbyViewModel( .stateIn(viewModelScope, SharingStarted.Eagerly, repository.suggestions.value) val roomClosedReason: StateFlow = repository.roomClosedReason .stateIn(viewModelScope, SharingStarted.Eagerly, repository.roomClosedReason.value) + val errors = repository.errors + + fun vote(suggestionId: String) = + launchOperation("Could not vote") { repository.vote(suggestionId) } + + fun unvote(suggestionId: String) = + launchOperation("Could not remove vote") { repository.unvote(suggestionId) } - fun vote(suggestionId: String) = viewModelScope.launch { repository.vote(suggestionId) } - fun unvote(suggestionId: String) = viewModelScope.launch { repository.unvote(suggestionId) } fun removeSuggestion(suggestionId: String) = - viewModelScope.launch { repository.deleteSuggestion(suggestionId) } + launchOperation("Could not remove suggestion") { + repository.deleteSuggestion(suggestionId) + } /** Host: promote a suggestion to the room selection (moves everyone to the player). */ fun promote(suggestionId: String) = - viewModelScope.launch { repository.promoteSuggestion(PromoteSuggestionRequest(suggestionId = suggestionId)) } + launchOperation("Could not start suggestion") { + repository.promoteSuggestion(PromoteSuggestionRequest(suggestionId = suggestionId)) + } - fun closeRoom() = viewModelScope.launch { repository.closeRoom() } + fun closeRoom() = launchOperation("Could not close room") { repository.closeRoom() } + + private fun launchOperation( + fallback: String, + operation: suspend () -> ApiResult, + ) = viewModelScope.launch { + val result = operation() + if (result !is ApiResult.Success) { + repository.reportDeliveryFailure(result.errorMessage(fallback)) + } + } /** Guest/host leave: tear down the WS + clear room state. */ fun leave() { diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySheet.kt new file mode 100644 index 000000000..d2e466b2b --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySheet.kt @@ -0,0 +1,127 @@ +package org.prairieserver.prairie.android.ui.screens.watchtogether + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.watchtogether.canDismissRoomEntry + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WatchTogetherMenuEntrySheet( + onNavigate: (String) -> Unit, + onDismiss: () -> Unit, + viewModel: WatchTogetherEntryViewModel = koinViewModel(), +) { + val state by viewModel.uiState.collectAsState() + val currentRoom by viewModel.currentRoom.collectAsState() + var code by rememberSaveable { mutableStateOf("") } + var showJoin by rememberSaveable { mutableStateOf(false) } + val latestBusy by rememberUpdatedState(state.busy) + + LaunchedEffect(state.destination) { + val destination = state.destination ?: return@LaunchedEffect + viewModel.consumeDestination() + onDismiss() + onNavigate(destination) + } + + ModalBottomSheet( + onDismissRequest = { + if (canDismissRoomEntry(state.busy)) { + viewModel.clearError() + onDismiss() + } + }, + sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { target -> + target != SheetValue.Hidden || canDismissRoomEntry(latestBusy) + }, + ), + ) { + Text( + text = "Watch Together", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + ) + HorizontalDivider() + + Column( + modifier = Modifier.fillMaxWidth().padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (!showJoin) { + if (currentRoom != null) { + Button( + onClick = { viewModel.resumeCurrentRoom() }, + enabled = !state.busy, + modifier = Modifier.fillMaxWidth(), + ) { Text("Resume current room") } + } + Button( + onClick = { viewModel.hostEmptyVoteRoom() }, + enabled = !state.busy, + modifier = Modifier.fillMaxWidth(), + ) { Text(if (state.busy) "Creating…" else "Host a room") } + OutlinedButton( + onClick = { viewModel.clearError(); showJoin = true }, + enabled = !state.busy, + modifier = Modifier.fillMaxWidth(), + ) { Text("Join by code") } + } else { + OutlinedTextField( + value = code, + onValueChange = { + code = it.uppercase().filter(Char::isLetterOrDigit).take(8) + }, + label = { Text("Invite code") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { viewModel.joinByCode(code) }, + enabled = !state.busy && code.length >= 4, + modifier = Modifier.fillMaxWidth(), + ) { + if (state.busy) CircularProgressIndicator(modifier = Modifier.size(18.dp)) + else Text("Join") + } + OutlinedButton( + onClick = { viewModel.clearError(); showJoin = false }, + enabled = !state.busy, + modifier = Modifier.fillMaxWidth(), + ) { Text("Back") } + } + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + Spacer(Modifier.height(24.dp)) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/ExternalRouteNavigationTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/ExternalRouteNavigationTest.kt new file mode 100644 index 000000000..3b83b579c --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/ExternalRouteNavigationTest.kt @@ -0,0 +1,296 @@ +package org.prairieserver.prairie.android.ui.navigation + +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.android.ui.screens.player.MobilePlayerRouteIntent +import org.prairieserver.prairie.android.ui.screens.player.MobilePlayerRouteTarget +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ExternalRouteNavigationTest { + @Test + fun contentRouteWaitsForAuthenticationAndIsDeliveredOnce() = runTest { + val events = mutableListOf() + val destinations = flow { + emit(Route.Login.route) + emit(Route.ProfileSelection.route) + emit(Route.Home.route) + error("external-route collector remained active after the eligible destination") + } + + consumeExternalRouteOnce( + pendingExternalRoute = ExternalRouteRequest( + generation = 1, + route = "player/movie-tmdb-463015", + ), + currentDestinationRoutes = destinations, + navigate = { route -> events += "navigate:$route" }, + onConsumed = { request -> events += "consumed:${request.generation}" }, + ) + + assertEquals( + listOf("navigate:player/movie-tmdb-463015", "consumed:1"), + events, + ) + } + + @Test + fun inviteClaimCanNavigateFromTheSignedOutGraph() = runTest { + val navigated = mutableListOf() + + consumeExternalRouteOnce( + pendingExternalRoute = ExternalRouteRequest( + generation = 1, + route = "invite_claim?server=example&token=test-token", + ), + currentDestinationRoutes = flowOf(Route.Login.route), + navigate = navigated::add, + onConsumed = {}, + ) + + assertEquals(listOf("invite_claim?server=example&token=test-token"), navigated) + } + + @Test + fun blankRouteDoesNotSubscribeOrNavigate() = runTest { + var navigations = 0 + + consumeExternalRouteOnce( + pendingExternalRoute = ExternalRouteRequest(generation = 1, route = " "), + currentDestinationRoutes = flow { error("blank route must not collect destinations") }, + navigate = { navigations++ }, + onConsumed = { error("blank route must not be consumed") }, + ) + + assertEquals(0, navigations) + } + + @Test + fun repeatedIdenticalRoutesReceiveDistinctMonotonicIdentities() { + val requests = ExternalRouteRequestFactory() + + val first = requests.create("player/movie-tmdb-463015") + val second = requests.create("player/movie-tmdb-463015") + + assertEquals(first.route, second.route) + assertNotEquals(first.generation, second.generation) + assertEquals(first.generation + 1, second.generation) + } + + @Test + fun playerTargetProviderIsInvokedOnlyForItsOwningBackStackEntry() { + var providerCalls = 0 + val registration = PlayerTargetProviderRegistration( + backStackEntryId = "player-a", + target = { + providerCalls += 1 + null + }, + ) + + assertNull( + currentPlayerTargetOrNull( + currentBackStackEntryId = "player-b", + registration = registration, + ), + ) + assertEquals(0, providerCalls) + + assertNull( + currentPlayerTargetOrNull( + currentBackStackEntryId = "player-a", + registration = registration, + ), + ) + assertEquals(1, providerCalls) + } + + @Test + fun staleConsumptionDoesNotClearANewerRequest() { + val requests = ExternalRouteRequestFactory() + val first = requests.create("player/movie-tmdb-463015") + val second = requests.create("player/movie-tmdb-463015") + + assertEquals( + second, + clearConsumedExternalRouteRequest( + pendingRequest = second, + consumedRequest = first, + ), + ) + assertNull( + clearConsumedExternalRouteRequest( + pendingRequest = second, + consumedRequest = second, + ), + ) + } + + @Test + fun exactCurrentTargetIsConsumedWithoutRenavigating() = runTest { + val request = ExternalRouteRequest( + generation = 4, + route = "player/movie-tmdb-463015", + ) + val events = mutableListOf() + + consumeExternalRouteOnce( + pendingExternalRoute = request, + currentDestinationRoutes = flowOf(Route.Player.ROUTE), + isAlreadyAtRoute = { route -> route == request.route }, + navigate = { route -> events += "navigate:$route" }, + onConsumed = { consumed -> events += "consumed:${consumed.generation}" }, + ) + + assertEquals(listOf("consumed:4"), events) + } + + @Test + fun differentPlayerTargetReplacesTheCurrentPlayerEntry() { + assertEquals( + true, + shouldReplaceCurrentPlayer( + currentDestinationRoute = Route.Player.ROUTE, + targetRoute = "player/movie-tmdb-463015?quality=original", + ), + ) + assertEquals( + false, + shouldReplaceCurrentPlayer( + currentDestinationRoute = Route.Home.route, + targetRoute = "player/movie-tmdb-463015?quality=original", + ), + ) + } + + @Test + fun canonicalPlayerTargetParsesEveryPlaybackChoice() { + assertEquals( + MobilePlayerRouteIntent( + contentId = "movie-1", + fileId = 121, + quality = "original", + audioTrackIndex = 2, + subtitleTrackIndex = -1, + resumePositionSeconds = 42.0, + ), + playerRouteIntentOrNull( + "player/movie-1?fileId=121&quality=original&audioTrackIndex=2&subtitleTrackIndex=-1&resumePosition=42.0", + ), + ) + assertNull(playerRouteIntentOrNull("item/movie-1")) + assertNull(playerRouteIntentOrNull("player/movie-1?fileId=invalid")) + assertNull(playerRouteIntentOrNull("player/movie-1?resumePosition=invalid")) + assertNull(playerRouteIntentOrNull("player/movie-1?roomId=room-1")) + } + + @Test + fun bareRouteRemainsExactAfterItsInitialAutomaticResolution() { + val intent = MobilePlayerRouteIntent(contentId = "movie-1") + val current = target( + intent = intent, + contentId = "movie-1", + fileId = 121, + quality = "auto", + audioTrackIndex = 1, + subtitleTrackIndex = -1, + ) + + assertTrue(requireNotNull(playerRouteIntentOrNull("player/movie-1")).matches(current)) + } + + @Test + fun bareRouteStopsMatchingAfterAnExplicitVersionSwitch() { + val intent = MobilePlayerRouteIntent(contentId = "movie-1", fileId = 222) + val current = target(intent = intent, contentId = "movie-1", fileId = 222) + + assertFalse(requireNotNull(playerRouteIntentOrNull("player/movie-1")).matches(current)) + assertTrue(requireNotNull(playerRouteIntentOrNull("player/movie-1?fileId=222")).matches(current)) + assertFalse(requireNotNull(playerRouteIntentOrNull("player/movie-1?fileId=121")).matches(current)) + } + + @Test + fun liveContentQualityAndTracksMustMatchExplicitRequest() { + val intent = MobilePlayerRouteIntent( + contentId = "episode-2", + fileId = 222, + quality = "720p", + audioTrackIndex = 1, + subtitleTrackIndex = -1, + ) + val current = target( + intent = intent, + contentId = "episode-2", + fileId = 222, + quality = "720p", + audioTrackIndex = 1, + subtitleTrackIndex = -1, + ) + + assertTrue( + requireNotNull( + playerRouteIntentOrNull( + "player/episode-2?fileId=222&quality=720p&audioTrackIndex=1&subtitleTrackIndex=-1", + ), + ).matches(current), + ) + assertFalse(requireNotNull(playerRouteIntentOrNull("player/episode-1?fileId=222")).matches(current)) + assertFalse(requireNotNull(playerRouteIntentOrNull("player/episode-2?quality=1080p")).matches(current)) + assertFalse(requireNotNull(playerRouteIntentOrNull("player/episode-2?audioTrackIndex=0")).matches(current)) + assertFalse(requireNotNull(playerRouteIntentOrNull("player/episode-2?subtitleTrackIndex=0")).matches(current)) + } + + @Test + fun resumePositionIsExactIntentRatherThanTheAdvancingPlaybackClock() { + val resumed = target( + intent = MobilePlayerRouteIntent( + contentId = "movie-1", + resumePositionSeconds = 42.0, + ), + contentId = "movie-1", + resumePositionSeconds = 42.0, + ) + val fromStart = target( + intent = MobilePlayerRouteIntent( + contentId = "movie-1", + resumePositionSeconds = 0.0, + ), + contentId = "movie-1", + resumePositionSeconds = 0.0, + ) + + assertTrue( + requireNotNull(playerRouteIntentOrNull("player/movie-1?resumePosition=42.0")) + .matches(resumed), + ) + assertFalse( + requireNotNull(playerRouteIntentOrNull("player/movie-1?resumePosition=43.0")) + .matches(resumed), + ) + assertFalse(requireNotNull(playerRouteIntentOrNull("player/movie-1")).matches(resumed)) + assertFalse(requireNotNull(playerRouteIntentOrNull("player/movie-1")).matches(fromStart)) + } + + private fun target( + intent: MobilePlayerRouteIntent, + contentId: String, + fileId: Int? = null, + quality: String? = null, + audioTrackIndex: Int? = null, + subtitleTrackIndex: Int? = null, + resumePositionSeconds: Double? = null, + ) = MobilePlayerRouteTarget( + intent = intent, + contentId = contentId, + fileId = fileId, + quality = quality, + audioTrackIndex = audioTrackIndex, + subtitleTrackIndex = subtitleTrackIndex, + resumePositionSeconds = resumePositionSeconds, + ) +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/PhoneDirectorCreditSourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/PhoneDirectorCreditSourceTest.kt new file mode 100644 index 000000000..fba213ca5 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/PhoneDirectorCreditSourceTest.kt @@ -0,0 +1,28 @@ +package org.prairieserver.prairie.android.ui.screens.detail + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class PhoneDirectorCreditSourceTest { + private val hero = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt", + ).readText() + private val movie = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt", + ).readText() + + @Test + fun phoneMovieHeroUsesSharedDirectorCredit() { + assertTrue(hero.contains("directorText: String? = null")) + assertTrue(movie.contains("directorText = movieDirectorCredit(detail)")) + } + + @Test + fun phoneCreditStaysBetweenTranslationAndFacts() { + val translation = hero.indexOf("translation?.invoke()") + val director = hero.indexOf("directorText?.takeIf") + val facts = hero.indexOf("if (factsLine.isNotEmpty())", startIndex = director) + assertTrue(translation >= 0 && translation < director && director < facts) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeasonPresentationLabelTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeasonPresentationLabelTest.kt new file mode 100644 index 000000000..c0f908ed9 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeasonPresentationLabelTest.kt @@ -0,0 +1,55 @@ +package org.prairieserver.prairie.android.ui.screens.detail + +import org.prairieserver.prairie.model.catalog.ItemDetail +import org.prairieserver.prairie.model.catalog.Season +import kotlin.test.Test +import kotlin.test.assertEquals + +class SeasonPresentationLabelTest { + @Test + fun seasonZeroWithoutSpecialsFlagUsesSpecialsLabel() { + assertEquals( + "Specials", + phoneSeasonLabel(Season(contentId = "specials", seasonNumber = 0)), + ) + } + + @Test + fun nonzeroSeasonWithSpecialsFlagUsesSpecialsLabel() { + assertEquals( + "Specials", + phoneSeasonLabel( + Season(contentId = "bonus", seasonNumber = 99, isSpecials = true), + ), + ) + } + + @Test + fun specialsOnlySelectionUsesSpecialsSectionHeader() { + assertEquals( + "Specials", + seriesSeasonSectionLabel(Season(contentId = "specials", seasonNumber = 0)), + ) + } + + @Test + fun specialsDownloadAccessibilityCopyNeverUsesSeasonZero() { + val specials = Season(contentId = "specials", seasonNumber = 0) + + assertEquals("Specials downloaded", seasonDownloadContentDescription(specials, true)) + assertEquals("Download specials", seasonDownloadContentDescription(specials, false)) + } + + @Test + fun specialsEpisodeEyebrowNeverUsesSeasonZero() { + val detail = ItemDetail( + contentId = "episode-special", + type = "episode", + title = "Bonus", + seasonNumber = 0, + episodeNumber = 3, + ) + + assertEquals("Specials · Episode 3", HeroMetadata.episodeEyebrow(detail)) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadataTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadataTest.kt new file mode 100644 index 000000000..cac28bf92 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadataTest.kt @@ -0,0 +1,150 @@ +package org.prairieserver.prairie.android.ui.screens.home + +import org.prairieserver.prairie.model.catalog.OverlaySummary +import org.prairieserver.prairie.model.section.SectionItem +import kotlin.test.Test +import kotlin.test.assertEquals + +class FeaturedHeroMetadataTest { + @Test + fun movieUsesOrderedEditorialMetadataWithoutGenericOrTechnicalChips() { + val chips = featuredHeroMetadata( + SectionItem( + contentId = "movie-1", + type = "movie", + title = "Arrival", + year = 2016, + genres = listOf("Science Fiction"), + ratingImdb = 7.9, + contentRating = "PG-13", + durationSeconds = 6_960.0, + overlaySummary = OverlaySummary( + resolution = "2160p", + hdr = "Dolby Vision", + audio = "Atmos", + ), + ), + ) + + assertEquals( + listOf("2016", "1h 56m", "7.9", "Science Fiction", "PG-13"), + chips.map { it.label }, + ) + assertEquals( + listOf( + FeaturedHeroMetadataKind.Plain, + FeaturedHeroMetadataKind.Plain, + FeaturedHeroMetadataKind.Rating, + FeaturedHeroMetadataKind.Plain, + FeaturedHeroMetadataKind.Classification, + ), + chips.map { it.kind }, + ) + } + + @Test + fun episodeReliesOnExistingSeriesEyebrowAndTitleWithoutDuplicatingName() { + val chips = featuredHeroMetadata( + SectionItem( + contentId = "episode-1", + type = "episode", + title = "Long, Long Time", + seriesTitle = "The Last of Us", + seasonNumber = 1, + episodeNumber = 3, + ratingImdb = 8.6, + contentRating = "TV-MA", + durationSeconds = 4_560.0, + ), + ) + + assertEquals( + listOf("S1 E3", "1h 16m", "8.6", "TV-MA"), + chips.map { it.label }, + ) + } + + @Test + fun invalidRatingsAndDurationsAreOmitted() { + listOf( + Double.NaN, + Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY, + 0.0, + -1.0, + 11.0, + ).forEachIndexed { index, invalid -> + val chips = featuredHeroMetadata( + SectionItem( + contentId = "invalid-$index", + type = "movie", + title = "Invalid", + ratingImdb = invalid, + durationSeconds = invalid, + ), + ) + + assertEquals(emptyList(), chips) + } + } + + @Test + fun invalidRatingDoesNotHideValidPhoneRuntime() { + val chips = featuredHeroMetadata( + SectionItem( + contentId = "runtime-with-invalid-rating", + type = "movie", + title = "Movie", + ratingImdb = Double.NaN, + durationSeconds = 7_200.0, + ), + ) + + assertEquals(listOf("2h"), chips.map { it.label }) + } + + @Test + fun validRatingDoesNotHideInvalidPhoneRuntime() { + val chips = featuredHeroMetadata( + SectionItem( + contentId = "rating-with-invalid-runtime", + type = "movie", + title = "Movie", + ratingImdb = 8.4, + durationSeconds = Double.NaN, + ), + ) + + assertEquals(listOf("8.4"), chips.map { it.label }) + } + + @Test + fun catalogRuntimeWinsOverPlaybackDurationOnPhone() { + val chips = featuredHeroMetadata( + SectionItem( + contentId = "movie-runtime", + type = "movie", + title = "Movie", + runtime = 125, + durationSeconds = 60.0, + ), + ) + + assertEquals(listOf("2h 5m"), chips.map { it.label }) + } + + @Test + fun invalidCatalogRuntimeFallsBackToPlaybackDurationOnPhone() { + val chips = featuredHeroMetadata( + SectionItem( + contentId = "movie-runtime-fallback", + type = "movie", + title = "Movie", + runtime = 0, + durationSeconds = 6_960.0, + ), + ) + + assertEquals(listOf("1h 56m"), chips.map { it.label }) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesViewModelTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesViewModelTest.kt new file mode 100644 index 000000000..f5b7e0c08 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesViewModelTest.kt @@ -0,0 +1,371 @@ +package org.prairieserver.prairie.android.ui.screens.libraries + +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.viewModelScope +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockRequestHandleScope +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import org.prairieserver.prairie.catalog.filter.CatalogFacet +import org.prairieserver.prairie.catalog.filter.CatalogFilterState +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.api.CatalogApi +import org.prairieserver.prairie.network.api.PersonalDataApi +import org.prairieserver.prairie.network.api.SectionApi +import org.prairieserver.prairie.repository.CatalogRepository +import org.prairieserver.prairie.repository.PersonalDataRepository +import org.prairieserver.prairie.repository.SectionRepository +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class LibrariesViewModelTest { + @Test + fun recommendedResponseFromPreviousLibraryCannotReplaceCurrentLibraryRows() = runTest { + val fixture = DeferredLibrariesFixture( + deferredKeys = setOf("sections:1", "sections:2"), + ) + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel() + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest("sections:1") + val staleRequest = viewModel.onlyActiveRequest() + + viewModel.selectLibrary(2) + fixture.awaitRequest("sections:2") + fixture.complete("sections:2", sectionsBody("current")) + viewModel.uiState.first { it.sections.map { section -> section.id } == listOf("current") } + + fixture.complete("sections:1", sectionsBody("stale")) + staleRequest.join() + + assertEquals(2, viewModel.uiState.value.selectedLibraryId) + assertEquals(listOf("current"), viewModel.uiState.value.sections.map { it.id }) + } finally { + store.clear() + Dispatchers.resetMain() + fixture.close() + } + } + + @Test + fun browseResponseFromPreviousSortCannotReplaceCurrentQueryGrid() = runTest { + val fixture = DeferredLibrariesFixture( + deferredKeys = setOf( + "catalog:1:added_at:desc", + "catalog:1:title:asc", + ), + ) + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel() + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest("sections:1") + viewModel.uiState.first { !it.isLoadingSections } + viewModel.selectTab(LibrariesSubtab.Browse) + fixture.awaitRequest("catalog:1:added_at:desc") + val staleRequest = viewModel.onlyActiveRequest() + + viewModel.selectBrowseSort(LibraryBrowseSort.Title) + fixture.awaitRequest("catalog:1:title:asc") + fixture.complete("catalog:1:title:asc", catalogBody("current")) + viewModel.uiState.first { + it.catalogItems.map { item -> item.contentId } == listOf("current") + } + + fixture.complete("catalog:1:added_at:desc", catalogBody("stale")) + staleRequest.join() + + assertEquals(LibraryBrowseSort.Title, viewModel.uiState.value.browseSort) + assertEquals(listOf("current"), viewModel.uiState.value.catalogItems.map { it.contentId }) + } finally { + store.clear() + Dispatchers.resetMain() + fixture.close() + } + } + + @Test + fun browseResponseFromPreviousFilterStateCannotReplaceCurrentQueryGrid() = runTest { + val fixture = DeferredLibrariesFixture( + deferredKeys = setOf( + "catalog:1:added_at:desc", + "catalog:1:added_at:desc:filtered", + ), + ) + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel() + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + viewModel.viewModelScope.launch { delay(1) } + fixture.awaitRequest("sections:1") + viewModel.uiState.first { !it.isLoadingSections } + viewModel.selectTab(LibrariesSubtab.Browse) + fixture.awaitRequest("catalog:1:added_at:desc") + val staleRequest = viewModel.onlyActiveRequest() + + val dramaOnly = CatalogFilterState( + selections = mapOf(CatalogFacet.Genre to setOf("Drama")), + ) + viewModel.applyFilterState(dramaOnly) + fixture.awaitRequest("catalog:1:added_at:desc:filtered") + fixture.complete( + "catalog:1:added_at:desc:filtered", + catalogBody("current-filter"), + ) + viewModel.uiState.first { + it.catalogItems.map { item -> item.contentId } == listOf("current-filter") + } + + fixture.complete("catalog:1:added_at:desc", catalogBody("stale-unfiltered")) + staleRequest.join() + + assertEquals(dramaOnly, viewModel.uiState.value.filterState) + assertEquals( + listOf("current-filter"), + viewModel.uiState.value.catalogItems.map { it.contentId }, + ) + } finally { + store.clear() + Dispatchers.resetMain() + fixture.close() + } + } + + @Test + fun delayedFilterVocabularyStillAppliesAfterLoadingNextPage() = runTest { + val firstPageKey = "catalog:1:added_at:desc:0" + val secondPageKey = "catalog:1:added_at:desc:1" + val fixture = DeferredLibrariesFixture( + deferredKeys = setOf("filters", firstPageKey, secondPageKey), + ) + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel() + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest("sections:1") + viewModel.uiState.first { !it.isLoadingSections } + viewModel.selectTab(LibrariesSubtab.Browse) + fixture.awaitRequest("filters") + fixture.awaitRequest(firstPageKey) + val filterRequest = viewModel.onlyActiveRequest() + fixture.complete(firstPageKey, catalogPageBody("page-1", hasMore = true)) + viewModel.uiState.first { + it.catalogItems.map { item -> item.contentId } == listOf("page-1") && + it.catalogHasMore + } + + viewModel.loadMoreCatalog() + fixture.awaitRequest(secondPageKey) + fixture.complete(secondPageKey, catalogPageBody("page-2", hasMore = false)) + viewModel.uiState.first { + it.catalogItems.map { item -> item.contentId } == listOf("page-1", "page-2") + } + + fixture.complete("filters", filtersBody("Drama")) + filterRequest.join() + + assertEquals(listOf("Drama"), viewModel.uiState.value.availableFilters?.genres) + } finally { + store.clear() + Dispatchers.resetMain() + fixture.close() + } + } + + @Test + fun collectionsResponseFromPreviousLibraryCannotReplaceCurrentLibraryCollections() = runTest { + val fixture = DeferredLibrariesFixture( + deferredKeys = setOf("collections:1", "collections:2"), + ) + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel() + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest("sections:1") + viewModel.uiState.first { !it.isLoadingSections } + viewModel.selectTab(LibrariesSubtab.Collections) + fixture.awaitRequest("collections:1") + val staleRequest = viewModel.onlyActiveRequest() + + viewModel.selectLibrary(2) + fixture.awaitRequest("collections:2") + fixture.complete("collections:2", collectionsBody("current")) + viewModel.uiState.first { + it.collections.map { collection -> collection.id } == listOf("current") + } + + fixture.complete("collections:1", collectionsBody("stale")) + staleRequest.join() + + assertEquals(2, viewModel.uiState.value.selectedLibraryId) + assertEquals(listOf("current"), viewModel.uiState.value.collections.map { it.id }) + } finally { + store.clear() + Dispatchers.resetMain() + fixture.close() + } + } + + private suspend fun LibrariesViewModel.onlyActiveRequest(): Job = withTimeout(5_000) { + while (true) { + val activeRequests = viewModelScope.coroutineContext[Job] + ?.children + ?.filter { it.isActive } + ?.toList() + .orEmpty() + when (activeRequests.size) { + 0 -> error("Expected an active Libraries request") + 1 -> return@withTimeout activeRequests.single() + else -> delay(1) + } + } + error("Unreachable") + } + + private class DeferredLibrariesFixture( + private val deferredKeys: Set, + ) { + private val requests = Channel(Channel.UNLIMITED) + private val pendingRequests = mutableListOf() + private val responses = deferredKeys.associateWith { CompletableDeferred() } + private val client = HttpClient( + MockEngine { request -> + val key = when (request.url.encodedPath) { + "/api/v1/user/libraries" -> "libraries" + "/api/v1/catalog/filters" -> "filters" + "/api/v1/catalog" -> { + val baseKey = "catalog:${request.url.parameters["library_id"]}:" + + "${request.url.parameters["sort"]}:${request.url.parameters["order"]}" + val filterSuffix = if ( + request.url.parameters.names().any { it.startsWith("groups[") } + ) { + ":filtered" + } else { + "" + } + val offsetSuffix = ":${request.url.parameters["offset"]}" + listOf( + baseKey + filterSuffix + offsetSuffix, + baseKey + filterSuffix, + baseKey + offsetSuffix, + baseKey, + ).firstOrNull(responses::containsKey) ?: (baseKey + filterSuffix) + } + else -> { + val segments = request.url.encodedPath.split('/') + val family = segments.last() + "$family:${segments[4]}" + } + } + requests.send(key) + val body = responses[key]?.await() ?: immediateBody(key) + respondJson(body) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + + fun viewModel() = LibrariesViewModel( + personalDataRepository = PersonalDataRepository(PersonalDataApi(client)), + sectionRepository = SectionRepository(SectionApi(client)), + catalogRepository = CatalogRepository(CatalogApi(client)), + ) + + suspend fun awaitRequest(expected: String) { + val pendingIndex = pendingRequests.indexOf(expected) + if (pendingIndex >= 0) { + pendingRequests.removeAt(pendingIndex) + return + } + while (true) { + val actual = requests.receive() + if (actual == expected) return + pendingRequests += actual + } + } + + fun complete(key: String, body: String) { + checkNotNull(responses[key]) { "No deferred response for $key" }.complete(body) + } + + fun close() { + client.close() + } + + private fun immediateBody(key: String): String = when { + key == "libraries" -> """ + [ + {"id":1,"name":"First","type":"movies","sort_order":0}, + {"id":2,"name":"Second","type":"movies","sort_order":1} + ] + """.trimIndent() + key == "filters" -> + """{"genres":[],"studios":[],"networks":[],"countries":[],"content_ratings":[]}""" + key.startsWith("sections:") -> """{"sections":[]}""" + key.startsWith("collections:") -> """{"collections":[]}""" + key.startsWith("catalog:") -> catalogBody("immediate") + else -> error("Unexpected request key $key") + } + + private fun MockRequestHandleScope.respondJson(body: String) = respond( + content = body, + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + + companion object { + private fun sectionsBody(id: String) = """ + { + "sections":[{ + "id":"$id", + "section_type":"recently_added", + "title":"$id", + "items":[{"content_id":"$id-item","type":"movie","title":"$id"}] + }] + } + """.trimIndent() + + private fun catalogBody(id: String) = """ + { + "total":1, + "has_more":false, + "items":[{"content_id":"$id","type":"movie","title":"$id"}] + } + """.trimIndent() + + private fun catalogPageBody(id: String, hasMore: Boolean) = """ + { + "total":2, + "has_more":$hasMore, + "items":[{"content_id":"$id","type":"movie","title":"$id"}] + } + """.trimIndent() + + private fun filtersBody(genre: String) = + """{"genres":["$genre"],"studios":[],"networks":[],"countries":[],"content_ratings":[]}""" + + private fun collectionsBody(id: String) = + """{"collections":[{"id":"$id","name":"$id"}]}""" + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibraryChromeInsetSourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibraryChromeInsetSourceTest.kt new file mode 100644 index 000000000..de02824da --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibraryChromeInsetSourceTest.kt @@ -0,0 +1,50 @@ +package org.prairieserver.prairie.android.ui.screens.libraries + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LibraryChromeInsetSourceTest { + private fun source(path: String): String { + val moduleRelative = File("src/androidMain/kotlin/$path") + val projectRelative = File("androidApp/src/androidMain/kotlin/$path") + return (moduleRelative.takeIf(File::exists) ?: projectRelative).readText() + } + + private val libraries = source( + "org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt", + ) + private val carousel = source( + "org/prairieserver/prairie/android/ui/screens/home/FeaturedCarousel.kt", + ) + private val catalogGrid = source( + "org/prairieserver/prairie/android/ui/screens/browse/CatalogGrid.kt", + ) + + @Test + fun sharedChromeOwnsReservedSpaceBeforeEveryLibraryTab() { + val chrome = libraries.indexOf("LibrariesFloatingChrome(") + val viewport = libraries.indexOf("LibraryContentViewport(") + assertTrue(chrome >= 0) + assertTrue(viewport > chrome) + assertTrue(libraries.contains("Modifier.weight(1f).clipToBounds()")) + } + + @Test + fun tabsDoNotCarryOverlayClearanceRunways() { + assertFalse(libraries.contains("LibrariesChromeContentHeight")) + assertFalse(libraries.contains("extraTopInset = 50.dp")) + assertFalse(libraries.contains(".windowInsetsPadding(WindowInsets.statusBars)")) + assertFalse(carousel.contains("WindowInsets.statusBars")) + assertTrue(carousel.contains("topInset: androidx.compose.ui.unit.Dp = 16.dp")) + } + + @Test + fun browseCatalogAndAlphabetRailReserveMeasuredBottomChromeInset() { + assertTrue(libraries.contains("bottomContentInset = LocalBottomChromeInset.current")) + assertTrue(catalogGrid.contains("bottomContentInset: Dp = 0.dp")) + assertTrue(catalogGrid.contains("bottom = 8.dp + bottomContentInset")) + assertTrue(catalogGrid.contains(".padding(bottom = bottomContentInset)")) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobilePlayerRouteTargetTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobilePlayerRouteTargetTest.kt new file mode 100644 index 000000000..417a3fbc2 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobilePlayerRouteTargetTest.kt @@ -0,0 +1,344 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import org.prairieserver.prairie.model.catalog.AudioTrack +import org.prairieserver.prairie.model.catalog.FileVersion +import org.prairieserver.prairie.model.catalog.SubtitleTrack +import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.model.playback.SubtitleMediaIdentity +import org.prairieserver.prairie.playback.decodeSubtitleIdentityPreference +import org.prairieserver.prairie.playback.encodeCatalogSubtitlePreference +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MobilePlayerRouteTargetTest { + @Test + fun readyTargetUsesLiveContentFileAudioAndCatalogSubtitleOrdinal() { + val catalogSubtitles = listOf( + SubtitleTrack(index = 10, language = "en", title = "English", external = true), + SubtitleTrack(index = 20, language = "fr", title = "French", external = true), + ) + val version = FileVersion( + fileId = 222, + audioTracks = listOf( + AudioTrack(index = 3, language = "en"), + AudioTrack(index = 7, language = "fr"), + ), + subtitleTracks = catalogSubtitles, + ) + val state = PlayerViewModel.PlayerUiState( + isLoading = false, + contentId = "episode-2", + streamUrl = "https://example.test/video.m3u8", + versions = listOf(version), + selectedVersionIndex = 0, + audioTracks = version.audioTracks.orEmpty(), + selectedAudioIndex = 1, + // Mounted order differs from the catalog order. Route values are + // catalog ordinals, so selectedSubtitleIndex must not be compared. + subtitleTracks = listOf( + PlayerSubtitleInfo(index = 1, language = "fr", label = "French", url = "fr.vtt"), + PlayerSubtitleInfo(index = 0, language = "en", label = "English", url = "en.vtt"), + ), + selectedSubtitleIndex = 1, + committedSubtitleIdentity = SubtitleIdentity.ServerSidecar(serverIndex = 0), + position = 95.0, + ) + + val target = requireNotNull( + mobilePlayerRouteTarget( + intent = MobilePlayerRouteIntent( + contentId = "episode-2", + resumePositionSeconds = 42.0, + ), + state = state, + ), + ) + + assertEquals("episode-2", target.contentId) + assertEquals(222, target.fileId) + assertEquals(1, target.audioTrackIndex) + assertEquals(0, target.subtitleTrackIndex) + assertEquals(42.0, target.resumePositionSeconds) + } + + @Test + fun pendingLoadUsesNewIntentInsteadOfStaleMountedState() { + val intent = MobilePlayerRouteIntent( + contentId = "episode-2", + fileId = 222, + quality = "720p", + audioTrackIndex = 1, + subtitleTrackIndex = -1, + resumePositionSeconds = 42.0, + ) + val target = requireNotNull( + mobilePlayerRouteTarget( + intent = intent, + state = PlayerViewModel.PlayerUiState( + isLoading = true, + contentId = "episode-2", + streamUrl = "https://example.test/stale-video.m3u8", + versions = listOf(FileVersion(fileId = 111)), + ), + ), + ) + + assertEquals(222, target.fileId) + assertEquals("720p", target.quality) + assertEquals(1, target.audioTrackIndex) + assertEquals(-1, target.subtitleTrackIndex) + assertEquals(42.0, target.resumePositionSeconds) + } + + @Test + fun catalogSubtitleOrdinalUsesStableIdentityAfterCatalogReorder() { + val originalCatalog = listOf( + SubtitleTrack( + index = 10, + language = "en", + title = "English", + codec = "srt", + external = true, + ), + SubtitleTrack( + index = 20, + language = "fr", + title = "French", + codec = "srt", + external = true, + ), + ) + val french = requireNotNull( + decodeSubtitleIdentityPreference( + encodeCatalogSubtitlePreference(originalCatalog, selectedOrdinal = 1), + ), + ) + val state = PlayerViewModel.PlayerUiState( + versions = listOf( + FileVersion( + fileId = 222, + subtitleTracks = listOf( + SubtitleTrack( + index = 20, + language = "fr", + title = "French", + codec = "srt", + external = true, + ), + SubtitleTrack( + index = 10, + language = "en", + title = "English", + codec = "srt", + external = true, + ), + ), + ), + ), + selectedVersionIndex = 0, + ) + + assertEquals(0, catalogSubtitleRouteOrdinal(state, french)) + assertEquals(-1, catalogSubtitleRouteOrdinal(state, SubtitleIdentity.Off)) + assertNull( + catalogSubtitleRouteOrdinal( + state, + SubtitleIdentity.Downloaded( + downloadId = 99, + media = SubtitleMediaIdentity(label = "French", language = "fr"), + ), + ), + ) + } + + @Test + fun versionSelectionChangesOnlyFileIntentAndRecoveryRestoresPriorIntent() { + val state = MobilePlayerRouteIntentState() + state.beginLoad( + contentId = "movie-1", + fileId = null, + quality = null, + audioTrackIndex = null, + subtitleTrackIndex = null, + resumePositionSeconds = 42.0, + preserveCurrent = false, + ) + val initial = requireNotNull(state.current) + + state.beginVersionSelection(contentId = "movie-1", fileId = 222) + + val switched = requireNotNull(state.current) + assertEquals(222, switched.fileId) + assertTrue(switched.fileIsExplicit) + assertFalse(switched.qualityIsExplicit) + assertFalse(switched.audioTrackIsExplicit) + assertFalse(switched.subtitleTrackIsExplicit) + assertEquals(42.0, switched.resumePositionSeconds) + + state.recoverVersionSelection(contentId = "movie-1") + assertEquals(initial, state.current) + } + + @Test + fun preservedExplicitQualityIsReappliedToTheActualLoad() { + val state = MobilePlayerRouteIntentState() + state.beginLoad( + contentId = "movie-1", + fileId = null, + quality = "720p", + audioTrackIndex = null, + subtitleTrackIndex = null, + resumePositionSeconds = null, + preserveCurrent = false, + ) + state.beginVersionSelection(contentId = "movie-1", fileId = 222) + state.beginLoad( + contentId = "movie-1", + fileId = 222, + quality = null, + audioTrackIndex = 1, + subtitleTrackIndex = -1, + resumePositionSeconds = null, + preserveCurrent = true, + ) + + assertEquals( + "720p", + state.qualityForLoad( + contentId = "movie-1", + normalizedRequestedQuality = null, + preserveCurrent = true, + ), + ) + } + + @Test + fun internalTrackRestoreStaysAutomaticAndUserIntentCommitsOnlyOnSuccess() { + val state = MobilePlayerRouteIntentState() + state.beginLoad( + contentId = "movie-1", + fileId = null, + quality = null, + audioTrackIndex = null, + subtitleTrackIndex = null, + resumePositionSeconds = null, + preserveCurrent = false, + ) + val french = SubtitleIdentity.ServerSidecar(serverIndex = 20) + + // A persisted/automatic restore has no staged user provenance. + state.applyCommittedTracks( + contentId = "movie-1", + committedAudioServerIndex = 7, + committedSubtitleIdentity = french, + transactionFailed = false, + transactionActive = false, + ) + assertFalse(requireNotNull(state.current).audioTrackIsExplicit) + assertFalse(requireNotNull(state.current).subtitleTrackIsExplicit) + + state.beginAudioSelection(contentId = "movie-1", routeOrdinal = 1, serverIndex = 7) + state.beginSubtitleSelection(contentId = "movie-1", routeOrdinal = 0, identity = french) + state.applyCommittedTracks( + contentId = "movie-1", + committedAudioServerIndex = 3, + committedSubtitleIdentity = SubtitleIdentity.Off, + transactionFailed = false, + transactionActive = true, + ) + assertFalse(requireNotNull(state.current).audioTrackIsExplicit) + assertFalse(requireNotNull(state.current).subtitleTrackIsExplicit) + + state.applyCommittedTracks( + contentId = "movie-1", + committedAudioServerIndex = 3, + committedSubtitleIdentity = SubtitleIdentity.Off, + transactionFailed = true, + transactionActive = false, + ) + assertFalse(requireNotNull(state.current).audioTrackIsExplicit) + assertFalse(requireNotNull(state.current).subtitleTrackIsExplicit) + + state.beginAudioSelection(contentId = "movie-1", routeOrdinal = 1, serverIndex = 7) + state.beginSubtitleSelection(contentId = "movie-1", routeOrdinal = 0, identity = french) + state.applyCommittedTracks( + contentId = "movie-1", + committedAudioServerIndex = 7, + committedSubtitleIdentity = french, + transactionFailed = false, + transactionActive = false, + ) + val committed = requireNotNull(state.current) + assertEquals(1, committed.audioTrackIndex) + assertTrue(committed.audioTrackIsExplicit) + assertEquals(0, committed.subtitleTrackIndex) + assertTrue(committed.subtitleTrackIsExplicit) + } + + @Test + fun operationalRestartPositionDoesNotBecomeFreshRouteResumeIntent() { + val state = MobilePlayerRouteIntentState() + state.beginLoad( + contentId = "episode-1", + fileId = null, + quality = null, + audioTrackIndex = null, + subtitleTrackIndex = null, + resumePositionSeconds = 42.0, + preserveCurrent = false, + ) + state.beginLoad( + contentId = "episode-1", + fileId = 111, + quality = null, + audioTrackIndex = 0, + subtitleTrackIndex = -1, + resumePositionSeconds = null, + preserveCurrent = true, + ) + assertEquals(42.0, requireNotNull(state.current).resumePositionSeconds) + + // Auto-advance can operationally start episode 2 at 0 while its route + // remains bare because it supplies no route resume provenance. + state.beginLoad( + contentId = "episode-2", + fileId = null, + quality = null, + audioTrackIndex = null, + subtitleTrackIndex = null, + resumePositionSeconds = null, + preserveCurrent = false, + ) + assertNull(requireNotNull(state.current).resumePositionSeconds) + } + + @Test + fun failedOrExitedPlayerDoesNotClaimAnExternalTarget() { + val intent = MobilePlayerRouteIntent(contentId = "movie-1") + + assertNull( + mobilePlayerRouteTarget( + intent, + PlayerViewModel.PlayerUiState( + isLoading = false, + error = "Playback failed", + contentId = "movie-1", + ), + ), + ) + assertNull( + mobilePlayerRouteTarget( + intent, + PlayerViewModel.PlayerUiState( + isLoading = false, + contentId = "movie-1", + streamUrl = null, + ), + ), + ) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapterTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapterTest.kt index 53c745912..3550e8d41 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapterTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapterTest.kt @@ -604,6 +604,16 @@ class MobileSubtitleTransactionAdapterTest { ) runCurrent() assertEquals(local, harness.adapter.snapshot.committedIdentity) + assertEquals(local, harness.adapter.snapshot.localMountIdentity) + assertNull(harness.adapter.snapshot.failureMessage) + + harness.adapter.reportMountedSelection( + identity = local, + selected = false, + snapshotKey = "ready-local-restore-miss", + settled = true, + ) + runCurrent() assertNull(harness.adapter.snapshot.localMountIdentity) assertTrue(harness.adapter.snapshot.failureMessage?.contains("mount", ignoreCase = true) == true) } @@ -782,7 +792,7 @@ class MobileSubtitleTransactionAdapterTest { } @Test - fun `settled local mount miss rolls back immediately without persistence`() = runTest { + fun `first settled local mount snapshot remains provisional`() = runTest { val harness = harness(backgroundScope) val local = SubtitleIdentity.LocalMedia3( media(label = "English", language = "en", codec = "webvtt"), @@ -797,12 +807,65 @@ class MobileSubtitleTransactionAdapterTest { ) runCurrent() + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertEquals(local, harness.adapter.snapshot.pendingIdentity) + assertEquals(local, harness.adapter.snapshot.localMountIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + assertNull(harness.adapter.snapshot.failureMessage) + + harness.adapter.reportMountedSelection( + identity = local, + selected = false, + snapshotKey = "ready-track-catalog", + settled = true, + ) + runCurrent() + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) assertNull(harness.adapter.snapshot.pendingIdentity) assertTrue(harness.persistence.persisted.isEmpty()) assertTrue(harness.adapter.snapshot.failureMessage?.contains("mount", ignoreCase = true) == true) } + @Test + fun `changed local mount snapshot must stabilize again before failure`() = runTest { + val harness = harness(backgroundScope) + val local = SubtitleIdentity.LocalMedia3( + media(label = "English", language = "en", codec = "webvtt"), + ) + + harness.adapter.select(local) + harness.adapter.reportMountedSelection( + identity = local, + selected = false, + snapshotKey = "initial-track-catalog", + settled = true, + ) + harness.adapter.reportMountedSelection( + identity = local, + selected = false, + snapshotKey = "changed-track-catalog", + settled = true, + ) + runCurrent() + + assertEquals(local, harness.adapter.snapshot.pendingIdentity) + assertEquals(local, harness.adapter.snapshot.localMountIdentity) + assertNull(harness.adapter.snapshot.failureMessage) + + harness.adapter.reportMountedSelection( + identity = local, + selected = false, + snapshotKey = "changed-track-catalog", + settled = true, + ) + runCurrent() + + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertNull(harness.adapter.snapshot.pendingIdentity) + assertTrue(harness.adapter.snapshot.failureMessage?.contains("mount", ignoreCase = true) == true) + } + @Test fun `repeated and empty local mount snapshots do not exhaust retry bound`() = runTest { val harness = harness(backgroundScope) diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerVerticalDragModeTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerVerticalDragModeTest.kt new file mode 100644 index 000000000..1094608ef --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerVerticalDragModeTest.kt @@ -0,0 +1,30 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import kotlin.test.Test +import kotlin.test.assertEquals + +class PlayerVerticalDragModeTest { + @Test + fun `left edge leaves system brightness authoritative`() { + assertEquals( + VerticalDragMode.None, + verticalDragMode(startX = 40f, width = 1_000f, edgeZonePx = 88f), + ) + } + + @Test + fun `right edge retains volume routing`() { + assertEquals( + VerticalDragMode.Volume, + verticalDragMode(startX = 950f, width = 1_000f, edgeZonePx = 88f), + ) + } + + @Test + fun `center retains dismiss routing`() { + assertEquals( + VerticalDragMode.DismissCandidate, + verticalDragMode(startX = 500f, width = 1_000f, edgeZonePx = 88f), + ) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt index a4818a9e9..debf160b6 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt @@ -453,6 +453,154 @@ class MobileVideoPlaybackStarterCancellationTest { } } +/** + * The phone starter must take its subtitle preferences from the server's + * resolved `effective_*` fields, the way TvVideoPlaybackStarter does. + * + * The settings screens write these preferences canonically now + * (`PUT /settings/values/{key}?scope=profile`); nothing on the server mirrors + * a canonical write back into `user_profiles`, so the profile columns + * `GET /profiles` serves are stale from the first edit. Reading them here is + * how the same profile ends up auto-selecting a different subtitle track on + * the phone than on the TV. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class MobileVideoPlaybackStarterSubtitlePreferenceTest { + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun serverResolvedSubtitlePreferencesWinOverTheStaleProfileColumns() = runTest(dispatcher) { + val ready = start( + effective = """ + "effective_subtitle_language": "ja", + "effective_subtitle_mode": "always", + "effective_show_forced_subtitles": false, + """.trimIndent(), + // What GET /profiles still serves after a canonical-only write. + profile = Profile( + id = PROFILE_ID, + name = "Profile", + subtitleLanguage = "en", + subtitleMode = "off", + showForcedSubtitles = true, + ), + ) + + assertEquals("ja", ready.preferredTextLanguage) + assertEquals("always", ready.preferredSubtitleMode) + assertFalse(ready.showForcedSubtitles) + } + + @Test + fun profileColumnsRemainTheFallbackWhenTheServerSendsNoResolvedValues() = + runTest(dispatcher) { + val ready = start( + effective = "", + profile = Profile( + id = PROFILE_ID, + name = "Profile", + subtitleLanguage = "de", + subtitleMode = "always", + showForcedSubtitles = false, + ), + ) + + assertEquals("de", ready.preferredTextLanguage) + assertEquals("always", ready.preferredSubtitleMode) + assertFalse(ready.showForcedSubtitles) + } + + private suspend fun TestScope.start( + effective: String, + profile: Profile, + ): VideoPlaybackStartResult.Ready { + val client = catalogClient(effective) + val tokenManager = FakeTokenManager() + val profileRepository = FakeProfileRepository(client, tokenManager, profile) + val manager = RecordingPlaybackSessionManager(client, tokenManager) + val context = ApplicationProvider.getApplicationContext() + val starter = MobileVideoPlaybackStarter( + catalogRepository = CatalogRepository(CatalogApi(client)), + playbackSessionManager = manager, + profileRepository = profileRepository, + capabilityDetector = PlaybackCapabilityDetector( + context, + AudioCapabilityManager(context), + LibassBridge(false), + ), + playerSettingsStore = FakePlayerSettingsStore(), + sessionLifecycle = PlaybackSessionLifecycle( + manager, + profileRepository, + HealthApi(client), + PersonalDataRepository(PersonalDataApi(client)), + backgroundScope, + ), + reachabilityMonitor = ServerReachabilityMonitor(HealthApi(client), backgroundScope), + sessionAllocator = { ApiResult.Success(allocatedReady("subtitle-session")) }, + sessionAdopter = { _, _ -> }, + ) + + val result = starter.start( + VideoPlaybackStartRequest( + contentId = "starter", + preferredFileId = 41, + roomId = null, + resumePositionOverride = null, + ), + ) + assertTrue(result is VideoPlaybackStartResult.Ready, "expected a ready start, got $result") + return result + } + + private fun catalogClient(effective: String): HttpClient = + HttpClient( + MockEngine { request -> + if (request.url.encodedPath == "/api/v1/watch/starter") { + respond( + content = """ + { + "content_id": "starter", + "type": "movie", + "title": "Starter", + $effective + "versions": [ + { + "file_id": 41, + "container": "mkv", + "duration": 120.0 + } + ] + } + """.trimIndent(), + status = HttpStatusCode.OK, + headers = JSON_HEADERS, + ) + } else { + respond( + content = """{"error":"not_found","message":"not found"}""", + status = HttpStatusCode.NotFound, + headers = JSON_HEADERS, + ) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } +} + private class DeferredNonCooperativeStarter : VideoPlaybackStarter { private data class Pending( val request: VideoPlaybackStartRequest, @@ -522,11 +670,11 @@ private class RecordingPlaybackSessionManager( private class FakeProfileRepository( client: HttpClient, tokenManager: TokenManager, + private val profile: Profile = Profile(id = PROFILE_ID, name = "Profile"), ) : ProfileRepository(ProfileApi(client), tokenManager) { override suspend fun getActiveProfileId(): String = PROFILE_ID - override suspend fun listProfiles(): ApiResult> = - ApiResult.Success(listOf(Profile(id = PROFILE_ID, name = "Profile"))) + override suspend fun listProfiles(): ApiResult> = ApiResult.Success(listOf(profile)) } private class FakeTokenManager : TokenManager { @@ -576,12 +724,12 @@ private class FakePlayerSettingsStore : PlayerSettingsStore { override val playbackSpeedFlow: Flow = flowOf(1.0) override val audioSyncMsFlow: Flow = flowOf(0) override val subtitleSyncMsFlow: Flow = flowOf(0) - override fun subtitleSyncMsFor(contentId: String?): Flow = subtitleSyncMsFlow override val nextUpPromptSecondsFlow: Flow = flowOf(30) override val sleepTimerDefaultMinutesFlow: Flow = flowOf(30) override val resumeRewindSecondsFlow: Flow = flowOf(7) override val passOutThresholdFlow: Flow = flowOf(3) override val preferredQualityFlow: Flow = flowOf("auto") + override val maxBitrateKbpsFlow: Flow = flowOf(null) override val audioLanguageFlow: Flow = flowOf("") override val videoGravityFlow: Flow = flowOf("fit") override val orientationModeFlow: Flow = flowOf("auto") @@ -608,16 +756,17 @@ private class FakePlayerSettingsStore : PlayerSettingsStore { override suspend fun setPlaybackSpeed(value: Double) = Unit override suspend fun setAudioSyncMs(value: Int) = Unit override suspend fun setSubtitleSyncMs(value: Int) = Unit - override suspend fun setSubtitleSyncMsFor(contentId: String, value: Int) = Unit override suspend fun setNextUpPromptSeconds(value: Int) = Unit override suspend fun setSleepTimerDefaultMinutes(value: Int) = Unit override suspend fun setResumeRewindSeconds(value: Int) = Unit override suspend fun setPassOutThreshold(value: Int) = Unit override suspend fun setPreferredQuality(value: String) = Unit + override suspend fun setQuality(resolution: String, bitrateKbps: Int?) = Unit override suspend fun setAudioLanguage(value: String) = Unit override suspend fun setVideoGravity(value: String) = Unit override suspend fun setOrientationMode(value: String) = Unit override suspend fun setSubtitleAppearance(value: SubtitleAppearance) = Unit + override suspend fun flushProjectedSubtitleAppearance() = Unit override suspend fun refreshFromServer() = Unit override suspend fun setSubtitleDeviceOverrideEnabled(enabled: Boolean) = Unit override suspend fun setSubtitleMatchesDevice(enabled: Boolean) = Unit diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomDeliveryKeySourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomDeliveryKeySourceTest.kt new file mode 100644 index 000000000..3a4bcb469 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomDeliveryKeySourceTest.kt @@ -0,0 +1,47 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class RoomDeliveryKeySourceTest { + private val reportingStartAnchor = "// Drift reporting loop" + private val reportingEndAnchor = "// ready / buffering during the waiting barrier." + + private val controllerSource = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomSyncController.kt", + ).readText() + + @Test + fun stateReportRequiresAnExplicitNonNullDeliveryKey() { + val reportingBlock = reportingBlock(controllerSource) + val guardIndex = reportingBlock.indexOf("deliveryKey != null") + val stateReportIndex = reportingBlock.indexOf("repository.stateReport(") + + assertFalse(reportingBlock.contains("deliveryKey!!")) + assertTrue(guardIndex >= 0, "Drift reporting block must guard deliveryKey explicitly.") + assertTrue(stateReportIndex >= 0, "Drift reporting block must report state.") + assertTrue(guardIndex < stateReportIndex) + } + + @Test + fun reportingBlockFailsClosedWhenEitherAnchorIsMissing() { + assertFailsWith { + reportingBlock(controllerSource.replace(reportingStartAnchor, "")) + } + assertFailsWith { + reportingBlock(controllerSource.replace(reportingEndAnchor, "")) + } + } + + private fun reportingBlock(source: String): String { + val startAnchorIndex = source.indexOf(reportingStartAnchor) + assertTrue(startAnchorIndex >= 0, "Missing drift-reporting start anchor.") + val blockStart = startAnchorIndex + reportingStartAnchor.length + val endAnchorIndex = source.indexOf(reportingEndAnchor, startIndex = blockStart) + assertTrue(endAnchorIndex >= blockStart, "Missing drift-reporting end anchor.") + return source.substring(blockStart, endAnchorIndex) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleAspectModeWiringSourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleAspectModeWiringSourceTest.kt new file mode 100644 index 000000000..6a20f2369 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleAspectModeWiringSourceTest.kt @@ -0,0 +1,43 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class SubtitleAspectModeWiringSourceTest { + private fun source(path: String): String { + val moduleRelative = File("src/androidMain/kotlin/$path") + val projectRelative = File("androidApp/src/androidMain/kotlin/$path") + return (moduleRelative.takeIf(File::exists) ?: projectRelative).readText() + } + + private fun playerViewUpdateBlock(source: String): String { + val factoryAnchor = "PlayerView(ctx).apply {" + val updateAnchor = "update = { view ->" + val endAnchor = "modifier = Modifier" + val factoryIndex = source.indexOf(factoryAnchor) + require(factoryIndex >= 0) { "PlayerView factory anchor is missing" } + val androidViewIndex = source.lastIndexOf("AndroidView(", factoryIndex) + require(androidViewIndex >= 0) { "Enclosing AndroidView is missing" } + val updateIndex = source.indexOf(updateAnchor, factoryIndex) + require(updateIndex > factoryIndex) { "PlayerView update lambda is missing or misordered" } + val endIndex = source.indexOf(endAnchor, updateIndex) + require(endIndex > updateIndex) { "PlayerView update lambda terminator is missing or misordered" } + return source.substring(updateIndex, endIndex) + } + + @Test + fun playerViewReconcilesSubtitlesAfterResizeModeUpdate() { + val source = source( + "org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt" + ) + val update = playerViewUpdateBlock(source) + + assertTrue(update.contains("view.resizeMode = resizeMode")) + assertTrue(update.contains("subtitleManager.syncSubtitleVideoBounds(view)")) + assertTrue( + update.indexOf("view.resizeMode = resizeMode") < + update.indexOf("subtitleManager.syncSubtitleVideoBounds(view)") + ) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleAspectSyncWiringTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleAspectSyncWiringTest.kt new file mode 100644 index 000000000..954db2f14 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleAspectSyncWiringTest.kt @@ -0,0 +1,20 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class SubtitleAspectSyncWiringTest { + @Test + fun phoneSubtitleManagerUsesPhonePresentation() { + val source = source("org/prairieserver/prairie/android/di/AndroidModule.kt") + + assertTrue(source.contains("SubtitleManager(\n libassBridge = get(),\n presentation = AndroidSubtitlePresentation.Phone,")) + } + + private fun source(path: String): String { + val moduleRelative = File("src/androidMain/kotlin/$path") + val projectRelative = File("androidApp/src/androidMain/kotlin/$path") + return (moduleRelative.takeIf(File::exists) ?: projectRelative).readText() + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/AvatarOptionsTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/AvatarOptionsTest.kt new file mode 100644 index 000000000..23e171602 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/AvatarOptionsTest.kt @@ -0,0 +1,17 @@ +package org.prairieserver.prairie.android.ui.screens.profiles + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class AvatarOptionsTest { + @Test + fun builtInAvatarsUseServerAcceptedDiceBearReferences() { + assertEquals(20, AvatarOptions.presets.distinct().size) + AvatarOptions.presets.forEach { avatarRef -> + val parts = avatarRef.split(':', limit = 4) + assertEquals(listOf("preset", "dicebear", "fun-emoji"), parts.take(3)) + assertTrue(parts[3].matches(Regex("[A-Za-z0-9-]{1,64}"))) + } + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModelTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModelTest.kt new file mode 100644 index 000000000..36e5c9b1e --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModelTest.kt @@ -0,0 +1,145 @@ +package org.prairieserver.prairie.android.ui.screens.watchtogether + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest +import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomResponse +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = android.app.Application::class) +class WatchTogetherEntryViewModelTest { + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun emptyHostCreatesVoteRoomWithoutSelection() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = WatchTogetherEntryViewModel(gateway) + + viewModel.hostEmptyVoteRoom() + + assertEquals(listOf(CreateRoomRequest(RoomSelectionMode.Vote.wire)), gateway.createRequests) + assertEquals(emptyList(), gateway.selectionRequests) + assertEquals("watch_together/room-1", viewModel.uiState.value.destination) + } + + @Test + fun resumeUsesCurrentRoomWithoutCreateOrJoin() = runTest(dispatcher) { + val room = RoomSnapshot(roomId = "room-1", phase = RoomPhase.Lobby) + val gateway = FakeGateway(room) + val viewModel = WatchTogetherEntryViewModel(gateway) + + viewModel.resumeCurrentRoom() + + assertEquals("watch_together/room-1", viewModel.uiState.value.destination) + assertEquals(emptyList(), gateway.createRequests) + assertEquals(emptyList(), gateway.joinRequests) + } + + @Test + fun identityClearRemovesResumeState() = runTest(dispatcher) { + val gateway = FakeGateway(RoomSnapshot(roomId = "room-1", phase = RoomPhase.Lobby)) + val viewModel = WatchTogetherEntryViewModel(gateway) + + gateway.roomSnapshot.value = null + + assertNull(viewModel.currentRoom.value) + } + + @Test + fun titleHostStillSetsTheSelectedTitle() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = WatchTogetherEntryViewModel(gateway) + + viewModel.host(contentId = "movie-1", fileId = 7) + + assertEquals( + listOf(SetSelectionRequest(contentId = "movie-1", fileId = 7)), + gateway.selectionRequests, + ) + } + + @Test + fun joinByCodeTrimsAndUsesExistingErrorMapping() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = WatchTogetherEntryViewModel(gateway) + + viewModel.joinByCode(" ABCD1234 ") + assertEquals(listOf(JoinRoomRequest(code = "ABCD1234")), gateway.joinRequests) + + viewModel.consumeDestination() + gateway.joinResult = ApiResult.NetworkError(IllegalStateException("offline")) + viewModel.joinByCode("EFGH5678") + assertEquals("Network error. Check your connection.", viewModel.uiState.value.error) + } + + private class FakeGateway( + room: RoomSnapshot? = null, + ) : WatchTogetherEntryGateway { + override val roomSnapshot = MutableStateFlow(room) + val createRequests = mutableListOf() + val joinRequests = mutableListOf() + val selectionRequests = mutableListOf() + var joinResult: ApiResult? = null + var nextRoom = RoomSnapshot( + roomId = "room-1", + phase = RoomPhase.Lobby, + selectionMode = RoomSelectionMode.Vote, + ) + + override suspend fun createRoom(request: CreateRoomRequest): ApiResult { + createRequests += request + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } + + override suspend fun joinRoom(request: JoinRoomRequest): ApiResult { + joinRequests += request + joinResult?.let { return it } + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } + + override suspend fun setSelection( + request: SetSelectionRequest, + ): ApiResult { + selectionRequests += request + nextRoom = nextRoom.copy( + selectedContentId = request.contentId, + selectedFileId = request.fileId, + ) + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyContinuitySourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyContinuitySourceTest.kt new file mode 100644 index 000000000..16a9f3260 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyContinuitySourceTest.kt @@ -0,0 +1,40 @@ +package org.prairieserver.prairie.android.ui.screens.watchtogether + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WatchTogetherLobbyContinuitySourceTest { + private val lobby = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt", + ).readText() + private val detail = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt", + ).readText() + private val movieDetail = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt", + ).readText() + private val seriesDetail = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeriesDetailContent.kt", + ).readText() + + @Test + fun ordinaryBackBrowsesWithoutLeavingAndLeaveIsExplicit() { + val navigationIcon = lobby.substringAfter("navigationIcon = {").substringBefore("actions = {") + assertTrue(navigationIcon.contains("onClick = onBack")) + assertFalse(navigationIcon.contains("viewModel.leave()")) + assertTrue(lobby.contains("Text(\"Leave room\")")) + assertTrue(lobby.contains("viewModel.leave()")) + } + + @Test + fun ownerControlsAndTitleSuggestionRemainReachable() { + assertTrue(lobby.contains("viewModel.vote(s.id)")) + assertTrue(lobby.contains("viewModel.promote(s.id)")) + assertTrue(lobby.contains("viewModel.closeRoom()")) + assertTrue(movieDetail.contains("Suggest to Watch Together")) + assertTrue(seriesDetail.contains("Suggest to Watch Together")) + assertTrue(detail.contains("suggestViewModel.suggest(")) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyErrorTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyErrorTest.kt new file mode 100644 index 000000000..69f78ce0d --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyErrorTest.kt @@ -0,0 +1,177 @@ +package org.prairieserver.prairie.android.ui.screens.watchtogether + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.prairieserver.prairie.model.watchtogether.AddSuggestionRequest +import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest +import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.PromoteSuggestionRequest +import org.prairieserver.prairie.model.watchtogether.RoomResponse +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.model.watchtogether.SuggestionsResponse +import org.prairieserver.prairie.model.watchtogether.UpdatePolicyRequest +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.api.WatchTogetherApi +import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.watchtogether.RoomSession +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = android.app.Application::class) +class WatchTogetherLobbyErrorTest { + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `rejected lobby operations use the transient repository message path`() = runTest(dispatcher) { + val api = FailingLobbyApi() + val repository = WatchTogetherRepository( + api = api, + authScopeProvider = { AUTH_SCOPE }, + ) + repository.createRoom(CreateRoomRequest()) + val roomSession = RoomSession(repository, backgroundScope, DefaultIdentityTransitionBarrier()) + val viewModel = WatchTogetherLobbyViewModel("room-1", repository, roomSession) + val messages = mutableListOf() + val collector = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.errors.toList(messages) + } + + viewModel.vote("suggestion-1") + viewModel.promote("suggestion-1") + repository.reportDeliveryFailure("Socket request rejected") + runCurrent() + + assertEquals( + listOf( + "Voting is disabled", + "Only the host can promote", + "Socket request rejected", + ), + messages, + ) + collector.cancel() + } + + @Test + fun `suggest rejection remains a visible one shot detail message`() = runTest(dispatcher) { + val repository = WatchTogetherRepository( + api = FailingLobbyApi(), + authScopeProvider = { AUTH_SCOPE }, + ) + repository.createRoom(CreateRoomRequest()) + val viewModel = SuggestToRoomViewModel(repository) + + viewModel.suggest("movie-1", "movie", "Movie One", null, null) + runCurrent() + + assertEquals("Suggestions are locked", viewModel.uiState.value.error) + viewModel.clearError() + assertEquals(null, viewModel.uiState.value.error) + } + + private class FailingLobbyApi : WatchTogetherApi { + private val roomResponse = + ApiResult.Success(RoomResponse(RoomSnapshot(roomId = "room-1"), "room-token")) + + override suspend fun createRoom(request: CreateRoomRequest, scope: AuthScopeSnapshot) = roomResponse + override suspend fun joinRoom(request: JoinRoomRequest, scope: AuthScopeSnapshot) = roomResponse + override suspend fun getRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = roomResponse + override suspend fun setSelection( + roomId: String, + roomToken: String, + request: SetSelectionRequest, + scope: AuthScopeSnapshot, + ) = roomResponse + + override suspend fun updatePolicy( + roomId: String, + roomToken: String, + request: UpdatePolicyRequest, + scope: AuthScopeSnapshot, + ) = roomResponse + + override suspend fun closeRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = + ApiResult.Success(Unit) + + override suspend fun listSuggestions( + roomId: String, + roomToken: String, + scope: AuthScopeSnapshot, + ) = ApiResult.Success(SuggestionsResponse()) + + override suspend fun addSuggestion( + roomId: String, + roomToken: String, + request: AddSuggestionRequest, + scope: AuthScopeSnapshot, + ): ApiResult = + ApiResult.Error(409, "suggestions_locked", "Suggestions are locked") + + override suspend fun deleteSuggestion( + roomId: String, + roomToken: String, + suggestionId: String, + scope: AuthScopeSnapshot, + ) = ApiResult.Success(SuggestionsResponse()) + + override suspend fun vote( + roomId: String, + roomToken: String, + suggestionId: String, + scope: AuthScopeSnapshot, + ): ApiResult = + ApiResult.Error(409, "voting_disabled", "Voting is disabled") + + override suspend fun unvote( + roomId: String, + roomToken: String, + suggestionId: String, + scope: AuthScopeSnapshot, + ) = ApiResult.Success(SuggestionsResponse()) + + override suspend fun promoteSuggestion( + roomId: String, + roomToken: String, + request: PromoteSuggestionRequest, + scope: AuthScopeSnapshot, + ): ApiResult = + ApiResult.Error(403, "host_required", "Only the host can promote") + } + + private companion object { + val AUTH_SCOPE = AuthScopeSnapshot( + serverId = "server-1", + profileId = "profile-1", + serverUrl = "https://example.test", + profileToken = "profile-token", + identityGeneration = 1L, + ) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySourceTest.kt new file mode 100644 index 000000000..eb2a6fba6 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySourceTest.kt @@ -0,0 +1,69 @@ +package org.prairieserver.prairie.android.ui.screens.watchtogether + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WatchTogetherMenuEntrySourceTest { + private fun source(path: String): String { + val moduleRelative = File("src/androidMain/kotlin/$path") + val projectRelative = File("androidApp/src/androidMain/kotlin/$path") + return (moduleRelative.takeIf(File::exists) ?: projectRelative).readText() + } + + private val topBar = source("org/prairieserver/prairie/android/ui/components/MainAppTopBar.kt") + private val home = source("org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt") + private val libraries = source("org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt") + private val main = source("org/prairieserver/prairie/android/ui/screens/MainScreen.kt") + private val menuSheet = source( + "org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySheet.kt", + ) + + @Test + fun everyPhoneProfileMenuPlacesWatchTogetherAfterRequestsAndBeforeSettings() { + listOf(topBar, home, libraries).forEach { text -> + val watch = text.indexOf("Text(\"Watch Together\")") + val requests = text.indexOf("Text(\"Requests\")") + val settings = text.indexOf("Text(\"Settings\")") + assertTrue(watch >= 0) + assertTrue(requests < 0 || requests < watch) + assertTrue(watch < settings) + if (requests >= 0) { + val watchMenuItem = text.lastIndexOf("DropdownMenuItem(", watch) + assertTrue(watchMenuItem > requests) + assertFalse( + text.substring( + startIndex = requests + "Text(\"Requests\")".length, + endIndex = watchMenuItem, + ).contains("DropdownMenuItem("), + ) + } + } + } + + @Test + fun mainShellOwnsOneTransientEntrySheet() { + assertTrue(main.contains("var showWatchTogetherEntry by rememberSaveable")) + assertTrue(main.contains("WatchTogetherMenuEntrySheet(")) + assertTrue(main.contains("CLIENT_WATCH_TOGETHER_SURFACE_ENABLED")) + assertTrue(main.contains("onWatchTogetherClick = watchTogetherMenuAction")) + } + + @Test + fun sheetUsesOnlyTheExistingControllerAndNeverHandlesCredentials() { + assertTrue(menuSheet.contains("viewModel.hostEmptyVoteRoom()")) + assertTrue(menuSheet.contains("viewModel.resumeCurrentRoom()")) + assertTrue(menuSheet.contains("viewModel.joinByCode(code)")) + listOf("Resume current room", "Host a room", "Join by code").forEach { label -> + assertTrue(menuSheet.contains(label)) + } + assertFalse(menuSheet.contains("WatchTogetherApi")) + assertFalse(menuSheet.contains("room_token")) + assertFalse(menuSheet.contains("roomAccessToken")) + assertFalse(menuSheet.contains("Authorization")) + assertFalse(menuSheet.contains("CleartextOriginConsent")) + assertFalse(menuSheet.contains("HttpClient")) + assertTrue(menuSheet.contains("state.error")) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/MainTvActivity.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/MainTvActivity.kt index 04e1491c0..cad8ec243 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/MainTvActivity.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/MainTvActivity.kt @@ -325,6 +325,9 @@ class MainTvActivity : ComponentActivity() { personalDataRepository = get(PersonalDataRepository::class.java), sectionRepository = get(SectionRepository::class.java), homeCache = get(HomeCachePort::class.java), + identityTransitions = get( + org.prairieserver.prairie.network.IdentityTransitionBarrier::class.java, + ), serverUrl = get(ServerRegistry::class.java).activeEntry.value?.url, artworkPlan = StartupArtworkPlan.tv(), ) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/data/preferences/LegacyTvPrefsMigration.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/data/preferences/LegacyTvPrefsMigration.kt index 5f324eb1c..24d6bcdf2 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/data/preferences/LegacyTvPrefsMigration.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/data/preferences/LegacyTvPrefsMigration.kt @@ -12,6 +12,7 @@ import org.prairieserver.prairie.common.settings.AndroidServerSettingsCache import org.prairieserver.prairie.common.settings.PlayerSettingsStore import org.prairieserver.prairie.model.settings.EffectiveSetting import org.prairieserver.prairie.model.settings.PlaybackSettingsKeys +import org.prairieserver.prairie.model.settings.QualityPresets import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset import kotlinx.coroutines.flow.first @@ -114,6 +115,13 @@ class LegacyTvPrefsMigration( val effective = getEffectiveSettings( listOf( PlaybackSettingsKeys.PreferredQuality, + // Quality is two rows now, and `setQuality` writes both. Asking + // only about the resolution would let a device that has a + // server-side bitrate cap but no resolution override pass the + // guard, and the legacy preset's bitrate — or JSON null, when + // the legacy value is Auto — would overwrite that cap. Both + // axes are queried so both can be guarded. + PlaybackSettingsKeys.MaxBitrateKbps, PlaybackSettingsKeys.AutoPlayNext, PlaybackSettingsKeys.AutoSkipIntro, PlaybackSettingsKeys.AutoSkipCredits, @@ -121,8 +129,29 @@ class LegacyTvPrefsMigration( ), ) - if (effective[PlaybackSettingsKeys.PreferredQuality]?.hasDeviceOverride != true) { - playerSettingsStore.setPreferredQuality(legacyQuality) + val qualityOverridden = + effective[PlaybackSettingsKeys.PreferredQuality]?.hasDeviceOverride == true || + effective[PlaybackSettingsKeys.MaxBitrateKbps]?.hasDeviceOverride == true + if (!qualityOverridden) { + // Both axes, never just the resolution. Quality is a + // (resolution, bitrate) pair now, and the legacy enum's bare + // "720p" carries an implied cap — the same one the server's own + // migration assigns it (internal/settingsmigrate/plan.go + // decomposes 720p to {720p, 2000}). Writing the resolution alone + // would leave a pair no preset covers, so the picker would render + // nothing as selected with the cursor parked on Auto, and the + // sentinel is marked on this pass so it could never be re-migrated. + // + // The legacy enum's wire values are exactly the base preset ids, + // so the id lookup lands on the same bitrate the server assigns + // (1080p -> 6000, 720p -> 2000, 480p -> 1500) rather than on + // whichever tier of that resolution happens to sort first. + val resolution = QualityPresets.normalizeResolution(legacyQuality) + val preset = QualityPresets.byId(resolution) + playerSettingsStore.setQuality( + preset?.resolution ?: resolution, + preset?.bitrateKbps, + ) } if (effective[PlaybackSettingsKeys.AutoPlayNext]?.hasDeviceOverride != true) { playerSettingsStore.setAutoPlayNext(legacyAutoPlayNext) 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..e4d52c86a 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 @@ -19,17 +19,18 @@ import org.prairieserver.prairie.network.createSecureSharedPrefs import org.prairieserver.prairie.tv.ui.screens.servers.TvServerListViewModel import org.prairieserver.prairie.common.player.AudioCapabilityManager import org.prairieserver.prairie.common.player.AudioTrackManager +import org.prairieserver.prairie.common.player.AndroidSubtitlePresentation import org.prairieserver.prairie.common.player.PlaybackCapabilityDetector import org.prairieserver.prairie.common.player.backend.VideoPlaybackBackendFactory import org.prairieserver.prairie.tv.ui.screens.settings.TvSettingsViewModel import org.prairieserver.prairie.tv.ui.screens.settings.diagnostics.TvDiagnosticsViewModel -import org.prairieserver.prairie.common.player.PrairiePlayerFactory +import org.prairieserver.prairie.common.player.SiloPlayerFactory import org.prairieserver.prairie.common.player.PlaybackSessionManager import org.prairieserver.prairie.common.player.SubtitleManager -import org.prairieserver.prairie.common.cast.PrairieCastNsdAdvertiser +import org.prairieserver.prairie.common.cast.SiloCastNsdAdvertiser import org.prairieserver.prairie.common.player.video.VideoPlaybackSessionCoordinator import org.prairieserver.prairie.common.player.video.VideoPlaybackStarter -import org.prairieserver.prairie.tv.cast.TvPrairieCastReceiver +import org.prairieserver.prairie.tv.cast.TvSiloCastReceiver import org.prairieserver.prairie.tv.cast.RemotePlaybackIdentityManager import org.prairieserver.prairie.tv.ui.screens.player.TvPlayerLaunchArgs import org.prairieserver.prairie.tv.ui.screens.auth.TvLoginViewModel @@ -40,6 +41,8 @@ import org.prairieserver.prairie.viewmodel.CalendarViewModel import org.prairieserver.prairie.viewmodel.CollectionsViewModel import org.prairieserver.prairie.tv.ui.screens.detail.TvItemDetailViewModel import org.prairieserver.prairie.viewmodel.HomeViewModel +import org.prairieserver.prairie.viewmodel.LiveTvViewModel +import org.prairieserver.prairie.viewmodel.LiveTvPlayerViewModel import org.prairieserver.prairie.viewmodel.RecommendationsViewModel import org.prairieserver.prairie.viewmodel.MyRequestsViewModel import org.prairieserver.prairie.viewmodel.RequestSearchViewModel @@ -63,16 +66,14 @@ import org.koin.android.ext.koin.androidContext import org.koin.core.module.dsl.viewModel import org.koin.core.qualifier.named import org.koin.dsl.module -import org.prairieserver.prairie.viewmodel.LiveTvPlayerViewModel -import org.prairieserver.prairie.viewmodel.LiveTvViewModel /** * TV-specific Koin module. * * Provides the TV flavor of player infrastructure (currently a duplicate of the - * phone module — see the TODO on [PrairiePlayerFactory]) and the TV ViewModels. + * phone module — see the TODO on [SiloPlayerFactory]) and the TV ViewModels. * The shared [org.prairieserver.prairie.di.sharedModules] is added alongside this one in - * [org.prairieserver.prairie.tv.PrairieTvApplication]. + * [org.prairieserver.prairie.tv.SiloTvApplication]. */ val androidTvModule = module { single { DataStoreCleartextConsentStore(androidContext()) } @@ -88,13 +89,13 @@ val androidTvModule = module { // Persistent (EncryptedSharedPreferences-backed) replacement for the // commonMain in-memory TokenManager. Koin 3.1+ replaces same-key bindings // when the redefining module is loaded after the original — sharedModules() - // is registered first in PrairieTvApplication, so this wins. + // is registered first in SiloTvApplication, so this wins. single { EncryptedTokenManagerImpl(get(), get(), get()) } // Offline-first Room store (Track B). Bound after sharedModules() so the // commonMain PersonalDataRepository's `getOrNull()` picks // up the Room-backed port and writes optimistic projection + outbox rows. - single { org.prairieserver.prairie.common.data.db.PrairieDatabase.build(androidContext()) } + single { org.prairieserver.prairie.common.data.db.SiloDatabase.build(androidContext()) } single { val appContext = androidContext().applicationContext org.prairieserver.prairie.common.data.sync.OutboxSyncScheduler { @@ -114,6 +115,7 @@ val androidTvModule = module { org.prairieserver.prairie.common.data.repository.RoomHomeCacheRepository( db = get(), snapshotProvider = { tokenManager.snapshotCurrentScope() }, + identityTransitions = get(), ) } single { @@ -121,6 +123,7 @@ val androidTvModule = module { org.prairieserver.prairie.common.data.repository.RoomCatalogCacheRepository( db = get(), snapshotProvider = { tokenManager.snapshotCurrentScope() }, + identityTransitions = get(), ) } single { @@ -141,7 +144,12 @@ val androidTvModule = module { AndroidDeviceMetadataProvider(androidContext(), platform = "android-tv") } // Player infrastructure (duplicate-for-now; extract to :android-player later). - single { SubtitleManager(get()) } + single { + SubtitleManager( + libassBridge = get(), + presentation = AndroidSubtitlePresentation.Television, + ) + } single { AudioTrackManager() } single { VideoPlaybackBackendFactory( @@ -153,7 +161,7 @@ val androidTvModule = module { single { AudioCapabilityManager(androidContext()) } single { PlaybackCapabilityDetector(androidContext(), get(), get()) } single { - PrairiePlayerFactory( + SiloPlayerFactory( context = androidContext(), tokenManager = get(), subtitleManager = get(), @@ -207,6 +215,7 @@ val androidTvModule = module { org.prairieserver.prairie.common.player.AudiobookPlayerViewModel( catalogRepository = get(), playbackSessionManager = get(), + playbackSessionLifecycle = get(), capabilityDetector = get(), bookmarksStore = get(), userItemStatePort = get(), @@ -252,7 +261,7 @@ val androidTvModule = module { // Watch Next launcher integration (TV-only). Repository wraps the // TvProvider ContentResolver; the worker is constructed by // TvWorkerFactory, installed via WorkManager.initialize in - // PrairieTvApplication (Koin's worker DSL is not used — see + // SiloTvApplication (Koin's worker DSL is not used — see // TvWorkerFactory for why). single { WatchNextRepository(androidContext()) } single { WatchNextSeeder(androidContext(), get()) } @@ -304,7 +313,7 @@ val androidTvModule = module { receiverStateProvider = { org.prairieserver.prairie.pairing.PairingReceiverState.Setup }, ) } - single { PrairieCastNsdAdvertiser(androidContext()) } + single { SiloCastNsdAdvertiser(androidContext()) } single { RemotePlaybackIdentityManager( deviceLoginApi = get(), @@ -313,7 +322,7 @@ val androidTvModule = module { ) } single { - TvPrairieCastReceiver( + TvSiloCastReceiver( advertiser = get(), serverRegistry = get(), identityManager = get(), @@ -360,7 +369,7 @@ val androidTvModule = module { } // Content ViewModels - viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull()) } + viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull(), get()) } viewModel { org.prairieserver.prairie.tv.ui.screens.home.TvUpcomingViewModel(get()) } viewModel { RecommendationsViewModel(get()) } viewModel { RequestsViewModel(get()) } @@ -419,6 +428,7 @@ val androidTvModule = module { personalDataRepository = get(), playerSettingsStore = get(), profileRepository = get(), + profileSettings = get(), metadataAiRepository = get(), contentId = params.get(), userItemState = getOrNull() @@ -490,6 +500,7 @@ val androidTvModule = module { libraryPlaybackPrefsStore = get(), overlayPrefsStore = get(), legacyTvPrefsMigration = get(), + profileSettings = get(), tvLibraryScopeStore = getOrNull(), ) } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAlphabetRail.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAlphabetRail.kt index 9d92d8543..f88e282e8 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAlphabetRail.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAlphabetRail.kt @@ -77,7 +77,7 @@ fun TvAlphabetRail( * the top menu is to register this fallback: on a letter it moves focus * up the rail; on "All" it swallows the move. */ - onUpFallbackChanged: (((() -> Boolean)?) -> Unit)? = null, + onUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, modifier: Modifier = Modifier, ) { val letters = remember { @@ -107,7 +107,7 @@ fun TvAlphabetRail( // old fresh-lambda + null pattern left a stale always-true fallback // registered forever, killing Up-to-menu-bar for the whole library. val railUpFallback = remember { - { + { _: Boolean -> if (!allEntryFocused) { focusManager.moveFocus(FocusDirection.Up) } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt index 2c643706c..e89f39fe9 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt @@ -27,10 +27,9 @@ data class TvMarqueeContent( val id: String, val title: String, val logoUrl: String?, - /** Codec/HDR + content-rating chips (`4K`, `DOLBY VISION`, `ATMOS`). */ + /** Optional uppercase content-classification chip. */ val badges: List, - /** Dot-joined meta tokens after the badges: year · genre · runtime, or - * `S2 E7 · episode title · 45 min · 23m left` for episodes. */ + /** Dot-joined editorial metadata after the badge. */ val metaParts: List, val synopsis: String?, /** A quieter detail line: cast / air-date when carried by the payload. */ @@ -75,32 +74,36 @@ data class TvMarqueeContent( } companion object { - fun from(item: SectionItem, rowTitle: String): TvMarqueeContent { + fun from( + item: SectionItem, + rowTitle: String, + rowIdentity: String = rowTitle, + ): TvMarqueeContent { val isEpisode = item.type.equals("episode", ignoreCase = true) val meta = mutableListOf() if (isEpisode) { episodeToken(item.seasonNumber, item.episodeNumber)?.let(meta::add) if (item.title.isNotBlank()) meta.add(item.title) - lengthText(item.durationSeconds)?.let(meta::add) - timeLeftText(item.positionSeconds, item.durationSeconds)?.let(meta::add) + lengthText(item.runtime, item.durationSeconds)?.let(meta::add) + ratingToken(item.ratingImdb)?.let(meta::add) } else { if (item.year > 0) meta.add(item.year.toString()) + lengthText(item.runtime, item.durationSeconds)?.let(meta::add) + ratingToken(item.ratingImdb)?.let(meta::add) item.genres.firstOrNull { it.isNotBlank() }?.let(meta::add) - lengthText(item.durationSeconds)?.let(meta::add) - item.ratingImdb?.let { meta.add(formatRating(it)) } } - // Codec/HDR + content-rating chips (`4K · DOLBY VISION · ATMOS · - // TV-MA`) derived from the section payload's overlay summary, then - // the content rating — mirrors tvOS `TVFocusMarquee.badges(from:)`. - val badges = qualityBadges(item.overlaySummary).toMutableList() - item.contentRating?.takeIf { it.isNotBlank() }?.let { badges.add(it.uppercase()) } + val badges = item.contentRating + ?.takeIf { it.isNotBlank() } + ?.uppercase(Locale.US) + ?.let(::listOf) + .orEmpty() val sectionBackdropUrl = item.backdropUrl?.takeIf { it.isNotBlank() } val sectionPosterUrl = item.posterUrl?.takeIf { it.isNotBlank() } return TvMarqueeContent( - id = "$rowTitle#${item.contentId}", + id = "$rowIdentity#${item.contentId}", title = if (isEpisode) (item.seriesTitle ?: item.title) else item.title, logoUrl = item.logoUrl?.takeIf { it.isNotBlank() }, badges = badges, @@ -123,59 +126,6 @@ data class TvMarqueeContent( ) } - /** - * Headline quality trio — resolution, dynamic range, audio — uppercased - * to the Skyline badge style, from the section payload's overlay summary. - * Mirrors tvOS `TVFocusMarquee.badges(from:)`. - */ - internal fun qualityBadges(summary: org.prairieserver.prairie.model.catalog.OverlaySummary?): List { - if (summary == null) return emptyList() - val badges = mutableListOf() - prettyResolution(summary.resolution)?.let(badges::add) - summary.hdr?.takeIf { it.isNotBlank() }?.let { hdr -> - badges.add(dynamicRangeBadge(hdr)) - } - summary.audio?.takeIf { it.isNotBlank() }?.let { audio -> - badges.add(audioBadge(audio)) - } - return badges.distinct() - } - - private fun dynamicRangeBadge(value: String): String { - val normalized = value.trim().lowercase(Locale.US) - return when { - normalized.contains("dolby vision") || - normalized.contains("dovi") || - Regex("(^|[^a-z])dv([^a-z]|$)").containsMatchIn(normalized) -> "DOLBY VISION" - normalized.contains("hdr10+") || normalized.contains("hdr10 plus") -> "HDR10+" - normalized.contains("hdr10") -> "HDR10" - normalized.contains("hlg") -> "HLG" - else -> value.trim().uppercase(Locale.US) - } - } - - private fun audioBadge(value: String): String { - val normalized = value.trim().lowercase(Locale.US) - return when { - normalized.contains("atmos") || - Regex("(^|[^a-z])joc([^a-z]|$)").containsMatchIn(normalized) -> "ATMOS" - normalized.contains("dts-hd") || normalized.contains("dts hd") -> "DTS-HD" - normalized.contains("truehd") || normalized.contains("true hd") -> "TRUEHD" - normalized.contains("e-ac-3") || normalized.contains("eac3") -> "EAC3" - normalized.contains("ac-3") || normalized == "ac3" -> "AC3" - else -> value.trim().uppercase(Locale.US) - } - } - - private fun prettyResolution(value: String?): String? { - val v = value?.takeIf { it.isNotBlank() } ?: return null - return when (v.lowercase()) { - "2160p", "4k", "uhd" -> "4K" - "4320p", "8k" -> "8K" - else -> v.uppercase() - } - } - private fun episodeToken(season: Int?, episode: Int?): String? = when { season != null && episode != null -> "S$season E$episode" season != null -> "Season $season" @@ -190,10 +140,17 @@ data class TvMarqueeContent( return "${remaining}m left" } - private fun lengthText(durationSeconds: Double?): String? { - if (durationSeconds == null || durationSeconds <= 0) return null - val minutes = (durationSeconds / 60.0).roundToInt() - if (minutes <= 0) return null + /** Episode/movie length: the metadata runtime when present, else + * derived from the file duration the payload already carries. */ + private fun lengthText(runtimeMinutes: Int?, durationSeconds: Double?): String? { + runtimeText(runtimeMinutes)?.let { return it } + val duration = durationSeconds?.takeIf { it.isFinite() && it > 0.0 } + ?: return null + return runtimeText((duration / 60.0).roundToInt()) + } + + private fun runtimeText(minutes: Int?): String? { + if (minutes == null || minutes <= 0) return null return if (minutes >= 60) { val hours = minutes / 60 val rest = minutes % 60 @@ -203,6 +160,12 @@ data class TvMarqueeContent( } } + private fun ratingToken(rating: Double?): String? = + validImdbRating(rating)?.let(::formatRating) + + private fun validImdbRating(rating: Double?): Double? = + rating?.takeIf { it.isFinite() && it > 0.0 && it <= 10.0 } + private fun formatRating(rating: Double): String { val rounded = (rating * 10).roundToInt() / 10.0 return rounded.toString() @@ -287,14 +250,21 @@ class TvFocusMarqueeState internal constructor() { internal var candidate: TvMarqueeContent? by mutableStateOf(null) + internal var focusedMarqueeId: String? by mutableStateOf(null) + private set + + internal val hasSettledRealFocus: Boolean + get() = focusedMarqueeId != null && focusedMarqueeId == content?.id + /** Per-contentId enrichment cache (tvOS `enrichmentCache`) so scrubbing * back over a row never refetches item detail. Persists for the page. */ private val enrichmentCache = mutableMapOf() private val enrichmentRequests = mutableSetOf() /** Report card focus. The displayed content swaps on the next composition turn. */ - fun preview(item: SectionItem, rowTitle: String) { - val next = TvMarqueeContent.from(item, rowTitle) + fun preview(item: SectionItem, rowTitle: String, rowIdentity: String = rowTitle) { + val next = TvMarqueeContent.from(item, rowTitle, rowIdentity) + focusedMarqueeId = next.id // Focus is back on the already-displayed card: cancel any pending swap // so a brief A→B→A scrub within the debounce window can't commit a // stale B after focus has returned to A. @@ -310,9 +280,11 @@ class TvFocusMarqueeState internal constructor() { * is only for page entry: once focus has produced displayed or pending * content, the seed is ignored so it never fights real navigation. */ - fun seedInitialPreview(item: SectionItem, rowTitle: String) { - if (content != null || candidate != null) return - candidate = TvMarqueeContent.from(item, rowTitle) + fun seedInitialPreview(item: SectionItem, rowTitle: String, rowIdentity: String = rowTitle) { + if (focusedMarqueeId != null) return + val next = TvMarqueeContent.from(item, rowTitle, rowIdentity) + if (candidate?.id == next.id || content?.id == next.id) return + candidate = next } internal fun commit(value: TvMarqueeContent?) { @@ -377,8 +349,9 @@ fun rememberTvFocusMarqueeState( // Populate the cache and enrich the active hero when identity still // matches. Near-viewport proactive prefetch usually wins this request; the // shared claim prevents duplicates when it is already in flight. - LaunchedEffect(state.content?.contentId, fetchDetail) { + LaunchedEffect(state.content?.id, state.focusedMarqueeId, fetchDetail) { val fetch = fetchDetail ?: return@LaunchedEffect + if (!state.hasSettledRealFocus) return@LaunchedEffect val contentId = state.content?.contentId ?: return@LaunchedEffect if (!state.beginEnrichmentRequest(contentId)) return@LaunchedEffect try { diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvOptionDialog.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvOptionDialog.kt index 5026530b6..7874f833f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvOptionDialog.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvOptionDialog.kt @@ -36,10 +36,15 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import androidx.compose.ui.window.PopupPositionProvider import androidx.tv.material3.Border import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api @@ -73,6 +78,7 @@ fun TvOptionDialog( ?: options.firstOrNull { it.enabled }?.key val focusedIndex = options.indexOfFirst { it.key == focusedKey } val listState: LazyListState = rememberLazyListState() + val popupPositionProvider = remember { TvOptionDialogWindowPositionProvider() } // Re-target focus when the dialog is reused for a new menu (title) or the // selected option changes; the shared helper below covers the initial grab. @@ -86,13 +92,13 @@ fun TvOptionDialog( } Popup( - alignment = Alignment.Center, + popupPositionProvider = popupPositionProvider, onDismissRequest = onDismiss, properties = PopupProperties( focusable = true, dismissOnBackPress = true, dismissOnClickOutside = true, - clippingEnabled = false, + clippingEnabled = true, ), ) { Box( @@ -157,6 +163,18 @@ fun TvOptionDialog( } } +internal class TvOptionDialogWindowPositionProvider : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset = IntOffset( + x = ((windowSize.width - popupContentSize.width) / 2).coerceAtLeast(0), + y = ((windowSize.height - popupContentSize.height) / 2).coerceAtLeast(0), + ) +} + @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun TvOptionDialogRow( diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePrefetchPolicy.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePrefetchPolicy.kt new file mode 100644 index 000000000..32cb8db10 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePrefetchPolicy.kt @@ -0,0 +1,42 @@ +package org.prairieserver.prairie.tv.ui.components + +import org.prairieserver.prairie.model.section.SectionItem + +internal data class TvSkylineSettledFocus( + val rowIndex: Int, + val contentId: String, +) + +internal fun settledFocusIdentity( + rawRowIndex: Int, + rawFocusedContentId: String?, + rawFocusedMarqueeId: String?, + settledMarqueeId: String?, +): TvSkylineSettledFocus? { + if ( + rawRowIndex < 0 || + rawFocusedContentId == null || + rawFocusedMarqueeId == null || + rawFocusedMarqueeId != settledMarqueeId + ) { + return null + } + return TvSkylineSettledFocus(rawRowIndex, rawFocusedContentId) +} + +internal fun settledPrefetchItems( + items: List, + rawFocusedContentId: String?, + settledContentId: String?, + radius: Int = 2, +): List { + if (rawFocusedContentId == null || rawFocusedContentId != settledContentId || radius <= 0) { + return emptyList() + } + val focusedIndex = items.indexOfFirst { it.contentId == settledContentId } + if (focusedIndex < 0) return emptyList() + + return ((focusedIndex - radius)..(focusedIndex + radius)) + .filter { it in items.indices && it != focusedIndex } + .map(items::get) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineSectionFeed.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineSectionFeed.kt index bee76fea9..6aee4e2a8 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineSectionFeed.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineSectionFeed.kt @@ -90,7 +90,7 @@ fun TvSkylineSectionFeed( if (it.isTvProgressRow()) TvRowStyle.Backdrop else TvRowStyle.Poster }, cardActions: (ResolvedSection, SectionItem) -> TvMediaCardActions = { _, _ -> TvMediaCardActions() }, - onContentUpFallbackChanged: (((() -> Boolean)?) -> Unit)? = null, + onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, ) { val rows = remember(sections) { sections.filter { it.items.isNotEmpty() } } val tintState = rememberAmbientBackdropTintState() @@ -99,109 +99,29 @@ fun TvSkylineSectionFeed( val catalogRepository: CatalogRepository = koinInject() val fetchDetail: suspend (String) -> ItemDetail? = remember(catalogRepository) { { contentId -> - (catalogRepository.getItemDetail(contentId) as? ApiResult.Success)?.data + (catalogRepository.getItemDetailForPrefetch(contentId) as? ApiResult.Success)?.data } } val marquee = rememberTvFocusMarqueeState(fetchDetail = fetchDetail) val initialMarqueeSeed = remember(rows) { rows.firstOrNull()?.let { section -> section.items.firstOrNull()?.let { item -> - TvSkylineMarqueeSeed(item = item, rowTitle = section.title) + TvSkylineMarqueeSeed( + item = item, + rowTitle = section.title, + rowIdentity = section.id, + ) } } } - LaunchedEffect(initialMarqueeSeed?.item?.contentId, initialMarqueeSeed?.rowTitle) { + LaunchedEffect( + initialMarqueeSeed?.item?.contentId, + initialMarqueeSeed?.rowTitle, + initialMarqueeSeed?.rowIdentity, + ) { val seed = initialMarqueeSeed ?: return@LaunchedEffect - if (marquee.content == null) { - marquee.seedInitialPreview(seed.item, seed.rowTitle) - } - } - - // Warm the hero-sized backdrop/logo variants for the cards the user can - // reach first. This is intentionally opportunistic: focus transitions - // never wait on the network, but the shared Crossfade usually receives a - // memory-cached image instead of a late ThumbHash replacement. - LaunchedEffect(rows) { - val requests = rows - .take(HeroPreloadRowCount) - .flatMap { it.items.take(HeroPreloadItemsPerRow) } - .flatMap { item -> - buildList { - item.backdropUrl?.takeIf { it.isNotBlank() }?.let { url -> - add( - ImageRequest.Builder(context) - .data(url) - .size(HeroBackdropPreloadWidthPx, HeroBackdropPreloadHeightPx) - .build(), - ) - } - item.logoUrl?.takeIf { it.isNotBlank() }?.let { url -> - add( - ImageRequest.Builder(context) - .data(url) - .size(HeroLogoPreloadWidthPx, HeroLogoPreloadHeightPx) - .build(), - ) - } - } - } - .distinctBy { it.data.toString() } - - val loader = SingletonImageLoader.get(context) - coroutineScope { - requests.map { request -> - async { runCatching { loader.execute(request) } } - }.awaitAll() - } - } - - // Section payloads intentionally stay lightweight and omit the aired/cast - // line. Warm detail for the same near-viewport cards whose hero artwork is - // preloaded so normal D-pad navigation presents a complete marquee on its - // first rested frame. The shared request guard prevents this from racing or - // duplicating the focus-driven fetch for the currently displayed card. - LaunchedEffect(rows, fetchDetail) { - val loader = SingletonImageLoader.get(context) - rows - .take(HeroPreloadRowCount) - .forEach { row -> - coroutineScope { - row.items - .take(HeroPreloadItemsPerRow) - .map { item -> - async { - val contentId = item.contentId - if (!marquee.beginEnrichmentRequest(contentId)) return@async - try { - val detail = runCatching { fetchDetail(contentId) }.getOrNull() - ?: return@async - val enrichment = TvMarqueeEnrichment.from(detail) - marquee.applyEnrichment(contentId, enrichment) - - // Warm a possible episode-series art upgrade - // at the exact hero decode size as well. - enrichment.backdropUrl?.takeIf { it.isNotBlank() }?.let { url -> - runCatching { - loader.execute( - ImageRequest.Builder(context) - .data(url) - .size( - HeroBackdropPreloadWidthPx, - HeroBackdropPreloadHeightPx, - ) - .build(), - ) - } - } - } finally { - marquee.finishEnrichmentRequest(contentId) - } - } - } - .awaitAll() - } - } + marquee.seedInitialPreview(seed.item, seed.rowTitle, seed.rowIdentity) } val rowBandState = rememberLazyListState() @@ -251,8 +171,9 @@ fun TvSkylineSectionFeed( val rowBandScope = rememberCoroutineScope() // Skyline matches tvOS' view-aligned row stack: vertical motion is owned by // this feed, while each row's LazyRow still handles horizontal card scroll. - val onItemFocused: (SectionItem, String, Int, Int) -> Unit = { item, rowTitle, rowIndex, itemIndex -> - marquee.preview(item, rowTitle) + val onItemFocused: (SectionItem, String, String, Int, Int) -> Unit = + { item, rowTitle, rowIdentity, rowIndex, itemIndex -> + marquee.preview(item, rowTitle, rowIdentity) focusedRowIndex = rowIndex focusedItemIndex = itemIndex focusedContentId = item.contentId @@ -269,17 +190,26 @@ fun TvSkylineSectionFeed( detailReturnPending = true } - // Keep the two cards immediately before and after focus hot. Because this - // window is established while the current card is focused, the next two - // D-pad moves in either direction already have logo/backdrop bytes and - // aired/cast enrichment in cache before their focus events arrive. - LaunchedEffect(rows, focusedRowIndex, focusedItemIndex, fetchDetail) { - val row = rows.getOrNull(focusedRowIndex) ?: return@LaunchedEffect - if (focusedItemIndex !in row.items.indices) return@LaunchedEffect - val window = ((focusedItemIndex - HeroFocusPrefetchRadius).. - (focusedItemIndex + HeroFocusPrefetchRadius)) - .filter { it in row.items.indices && it != focusedItemIndex } - .map(row.items::get) + // Keep a small window around RESTED focus hot. A raw D-pad move cancels the + // previous job immediately, but the new identity starts no speculative + // work until the marquee's focus-rest transaction commits it. + val settledFocus = settledFocusIdentity( + rawRowIndex = focusedRowIndex, + rawFocusedContentId = focusedContentId, + rawFocusedMarqueeId = rows.getOrNull(focusedRowIndex) + ?.let { row -> focusedContentId?.let { contentId -> "${row.id}#$contentId" } }, + settledMarqueeId = marquee.content?.id, + ) + LaunchedEffect(rows, settledFocus, fetchDetail) { + val focus = settledFocus ?: return@LaunchedEffect + val row = rows.getOrNull(focus.rowIndex) ?: return@LaunchedEffect + val window = settledPrefetchItems( + items = row.items, + rawFocusedContentId = focus.contentId, + settledContentId = focus.contentId, + radius = HeroFocusPrefetchRadius, + ) + if (window.isEmpty()) return@LaunchedEffect val loader = SingletonImageLoader.get(context) coroutineScope { @@ -346,26 +276,35 @@ fun TvSkylineSectionFeed( } } - val currentContentUpFallback = rememberUpdatedState<() -> Boolean> { + var rowRelocationInFlight by remember { mutableStateOf(false) } + val currentContentUpFallback = rememberUpdatedState<(Boolean) -> Boolean> { isRepeat -> val currentRow = focusedRowIndex - when { - currentRow <= 0 || currentRow !in rows.indices -> - // Top row (or unfocused): report not-handled so the shell hands - // focus to the menu bar. - false - // Previous row is already laid out: move immediately so the returned - // value is HONEST — the old code launched the move asynchronously and - // returned `true` before it ran, so a failed move stranded focus - // (neither moved up nor escalated to the menu). - focusManager.moveFocus(FocusDirection.Up) -> true - else -> { + when ( + tvSkylineUpAction( + currentRow = currentRow, + rowCount = rows.size, + isRepeat = isRepeat, + relocationInFlight = rowRelocationInFlight, + ) + ) { + TvSkylineUpAction.EnterMenu -> false + TvSkylineUpAction.StayInContent -> true + TvSkylineUpAction.TryPreviousRow -> { + if (focusManager.moveFocus(FocusDirection.Up)) { + return@rememberUpdatedState true + } // Previous row is scrolled off; bring it on-screen first, then - // move once the scroll settles (animateScrollToItem suspends until - // it does, so the row is laid out before moveFocus). + // move once the scroll settles. While this job owns relocation, + // key repeats are consumed instead of launching competing jobs. + rowRelocationInFlight = true rowBandScope.launch { - rowBandState.animateScrollToItem(currentRow - 1) - withFrameNanos { } - focusManager.moveFocus(FocusDirection.Up) + try { + rowBandState.animateScrollToItem(currentRow - 1) + withFrameNanos { } + focusManager.moveFocus(FocusDirection.Up) + } finally { + rowRelocationInFlight = false + } } true } @@ -374,8 +313,8 @@ fun TvSkylineSectionFeed( // Stable per-screen registration so the shell can identify THIS feed's // ownership of the shared up-fallback slot across sibling (tab) swaps. - val contentUpFallbackRegistration: () -> Boolean = - remember { { currentContentUpFallback.value() } } + val contentUpFallbackRegistration: (Boolean) -> Boolean = + remember { { isRepeat -> currentContentUpFallback.value(isRepeat) } } DisposableEffect(onContentUpFallbackChanged, contentUpFallbackRegistration) { onContentUpFallbackChanged?.invoke(contentUpFallbackRegistration) @@ -695,7 +634,7 @@ fun TvSkylineSectionFeed( restoreFocusRequester = detailReturnItemFocusRequester .takeIf { isReturnRow }, onItemFocusedAtIndex = { item, itemIndex -> - onItemFocused(item, section.title, rowIndex, itemIndex) + onItemFocused(item, section.title, section.id, rowIndex, itemIndex) }, cardActions = { item -> cardActions(section, item) }, ) @@ -718,6 +657,7 @@ fun ResolvedSection.isTvProgressRow(): Boolean { private data class TvSkylineMarqueeSeed( val item: SectionItem, val rowTitle: String, + val rowIdentity: String, ) // 0.64 × 1920 by 0.70 × 1080, and the 440×100dp logo cap at 2× density. @@ -725,8 +665,6 @@ private const val HeroBackdropPreloadWidthPx = 1229 private const val HeroBackdropPreloadHeightPx = 756 private const val HeroLogoPreloadWidthPx = 880 private const val HeroLogoPreloadHeightPx = 200 -private const val HeroPreloadRowCount = 2 -private const val HeroPreloadItemsPerRow = 8 private const val HeroFocusPrefetchRadius = 2 /** tvOS MediaRow cardSpacing 40pt maps to 20dp. */ diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineUpNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineUpNavigation.kt new file mode 100644 index 000000000..667e75a72 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineUpNavigation.kt @@ -0,0 +1,21 @@ +package org.prairieserver.prairie.tv.ui.components + +internal enum class TvSkylineUpAction { + EnterMenu, + StayInContent, + TryPreviousRow, +} + +internal fun tvSkylineUpAction( + currentRow: Int, + rowCount: Int, + isRepeat: Boolean, + relocationInFlight: Boolean, +): TvSkylineUpAction = when { + relocationInFlight -> TvSkylineUpAction.StayInContent + currentRow !in 0 until rowCount -> + if (isRepeat) TvSkylineUpAction.StayInContent else TvSkylineUpAction.EnterMenu + currentRow == 0 -> + if (isRepeat) TvSkylineUpAction.StayInContent else TvSkylineUpAction.EnterMenu + else -> TvSkylineUpAction.TryPreviousRow +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt index 59c3e631a..66b5e94d6 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt @@ -46,15 +46,15 @@ import org.prairieserver.prairie.tv.ui.screens.settings.diagnostics.TvDiagnostic import org.prairieserver.prairie.tv.ui.screens.settings.diagnostics.TvDiagnosticsReportScreen import org.prairieserver.prairie.tv.ui.screens.settings.diagnostics.TvDiagnosticsSettingsScreen import org.prairieserver.prairie.tv.ui.screens.settings.diagnostics.TvDiagnosticsViewModel -import org.prairieserver.prairie.model.watchtogether.MemberRole import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherLobbyScreen +import org.prairieserver.prairie.tv.ui.screens.watchtogether.tvWatchTogetherDestination import org.prairieserver.prairie.common.overlays.ProvideCardOverlays import org.prairieserver.prairie.common.diagnostics.DiagnosticsLifecycleLogger import org.prairieserver.prairie.common.settings.LibraryPlaybackPrefsStore import org.prairieserver.prairie.common.settings.OverlayPrefsStore import org.prairieserver.prairie.tv.watchnext.WatchNextSeeder -import org.prairieserver.prairie.tv.cast.TvPrairieCastReceiver -import org.prairieserver.prairie.tv.ui.screens.cast.TvPrairieCastStandbyView +import org.prairieserver.prairie.tv.cast.TvSiloCastReceiver +import org.prairieserver.prairie.tv.ui.screens.cast.TvSiloCastStandbyView import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -119,15 +119,15 @@ fun TvAppNavigation( val overlayPrefsStore: OverlayPrefsStore = koinInject() val libraryPlaybackPrefsStore: LibraryPlaybackPrefsStore = koinInject() val watchNextSeeder: WatchNextSeeder = koinInject() - val prairieCastReceiver: TvPrairieCastReceiver = koinInject() + val siloCastReceiver: TvSiloCastReceiver = koinInject() val diagnosticsViewModel = koinViewModel() val diagnosticsState by diagnosticsViewModel.state.collectAsState() val pendingDeepLink: MutableStateFlow = koinInject(qualifier = named("pendingDeepLink")) - val prairieCastStandby by prairieCastReceiver.standbyState.collectAsState() + val siloCastStandby by siloCastReceiver.standbyState.collectAsState() - LaunchedEffect(prairieCastReceiver) { - prairieCastReceiver.launchRequests.collect { request -> + LaunchedEffect(siloCastReceiver) { + siloCastReceiver.launchRequests.collect { request -> val playback = request.playback val destination = TvRoute.Player( contentId = playback.contentId, @@ -501,6 +501,11 @@ fun TvAppNavigation( launchSingleTop = true } }, + onOpenWatchTogether = { room -> + navController.navigate(tvWatchTogetherDestination(room)) { + launchSingleTop = true + } + }, onOpenLibraryCollectionDetail = { libraryId, collectionId, title -> navController.navigate( TvRoute.LibraryCollectionDetail(libraryId, collectionId, title).route, @@ -682,24 +687,8 @@ fun TvAppNavigation( launchSingleTop = true } }, - // Watch Together: the entry dialog resolves a room snapshot; route - // host-with-selection straight to the synced player (carrying - // roomId), otherwise into the lobby to wait/vote/pick. onWatchTogether = { snapshot -> - val hostAlone = - snapshot.selfRole == MemberRole.Host && snapshot.memberCount <= 1 - val target = if (!snapshot.selectedContentId.isNullOrBlank() && !hostAlone) { - TvRoute.Player( - contentId = snapshot.selectedContentId!!, - fileId = snapshot.selectedFileId, - roomId = snapshot.roomId, - resumePositionSeconds = snapshot.anchorPositionSeconds - .takeIf { it.isFinite() && it > 0.0 }, - ).route - } else { - TvRoute.WatchTogetherLobby(roomId = snapshot.roomId).route - } - navController.navigate(target) + navController.navigate(tvWatchTogetherDestination(snapshot)) }, onOpenPerson = { personId -> navController.navigate(TvRoute.PersonDetail(personId).route) @@ -962,10 +951,10 @@ fun TvAppNavigation( ) } } - prairieCastStandby?.let { state -> - TvPrairieCastStandbyView( + siloCastStandby?.let { state -> + TvSiloCastStandbyView( state = state, - onDisconnect = prairieCastReceiver::disconnectRemoteControl, + onDisconnect = siloCastReceiver::disconnectRemoteControl, ) } diagnosticsState.prompt diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairDeviceScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairDeviceScreen.kt index b67b19afe..2e6f63b70 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairDeviceScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairDeviceScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -89,7 +90,11 @@ fun TvPairDeviceScreen( ) { Column( modifier = Modifier - .widthIn(max = 360.dp) + // 480dp content width plus the 48dp horizontal padding on + // each side. On narrower displays fillMaxWidth keeps the + // panel responsive instead of clipping long match phrases. + .widthIn(max = 576.dp) + .fillMaxWidth() .verticalScroll(rememberScrollState()) .padding(horizontal = 48.dp, vertical = 48.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -245,7 +250,7 @@ private fun DetailRow(label: String, value: String) { if (value.isBlank()) return Row( modifier = Modifier - .widthIn(max = 480.dp) + .fillMaxWidth() .padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, @@ -261,6 +266,7 @@ private fun DetailRow(label: String, value: String) { style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground, fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarScreen.kt index e9ae92db4..c8397ddef 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -124,8 +125,9 @@ import java.util.Locale fun TvCalendarScreen( onOpenItemDetail: (contentId: String) -> Unit, onInitialContentFocus: () -> Unit = {}, + onMoveUpToMenu: () -> Unit = {}, focusRequest: Int = 0, - onContentUpFallbackChanged: (((() -> Boolean)?) -> Unit)? = null, + onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, viewModel: CalendarViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() @@ -250,6 +252,7 @@ fun TvCalendarScreen( // naturally pushes both controls upward instead of pinning them. CalendarList( onContentUpFallbackChanged = onContentUpFallbackChanged, + onMoveUpToMenu = onMoveUpToMenu, state = state, listState = listState, controls = controls, @@ -674,12 +677,36 @@ internal fun shouldReturnCalendarFocusToControls( focusedShelfIndex == firstFocusableShelfIndex && !isReturningToControls +internal enum class CalendarUpFallbackAction { + EnterMenu, + ReturnToControls, + StayInContent, + MoveWithinContent, +} + +internal fun calendarUpFallbackAction( + focusedShelfIndex: Int?, + firstFocusableShelfIndex: Int, + isReturningToControls: Boolean, + isRepeat: Boolean = false, +): CalendarUpFallbackAction = when { + shouldReturnCalendarFocusToControls( + focusedShelfIndex = focusedShelfIndex, + firstFocusableShelfIndex = firstFocusableShelfIndex, + isReturningToControls = isReturningToControls, + ) -> if (isRepeat) CalendarUpFallbackAction.StayInContent else CalendarUpFallbackAction.ReturnToControls + focusedShelfIndex == null && isRepeat -> CalendarUpFallbackAction.StayInContent + focusedShelfIndex == null -> CalendarUpFallbackAction.EnterMenu + else -> CalendarUpFallbackAction.MoveWithinContent +} + // MARK: - Day list @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class, ExperimentalTvMaterial3Api::class) @Composable private fun CalendarList( - onContentUpFallbackChanged: (((() -> Boolean)?) -> Unit)? = null, + onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, + onMoveUpToMenu: () -> Unit = {}, state: org.prairieserver.prairie.viewmodel.CalendarUiState, listState: LazyListState, controls: @Composable () -> Unit, @@ -731,24 +758,37 @@ private fun CalendarList( // when focus is already in the controls item, mirror the shell's default // (moveFocus within content; false -> shell hands off to the menu bar). var focusedShelfIndex by remember { mutableStateOf(null) } - val calendarUpFallback = remember(firstFocusableDayIndex) { - { - if (shouldReturnCalendarFocusToControls( + val currentCalendarUpFallback = rememberUpdatedState<(Boolean) -> Boolean> { isRepeat -> + when (calendarUpFallbackAction( focusedShelfIndex = focusedShelfIndex, firstFocusableShelfIndex = firstFocusableDayIndex, isReturningToControls = isReturningToControls, + isRepeat = isRepeat, ) ) { - onMoveUpToControls() - true - } else { - focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up) + CalendarUpFallbackAction.EnterMenu -> { + onMoveUpToMenu() + true + } + CalendarUpFallbackAction.ReturnToControls -> { + onMoveUpToControls() + true + } + CalendarUpFallbackAction.StayInContent -> true + CalendarUpFallbackAction.MoveWithinContent -> { + focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up) + } } - } } - DisposableEffect(onContentUpFallbackChanged) { - onContentUpFallbackChanged?.invoke(calendarUpFallback) - onDispose { onContentUpFallbackChanged?.invoke(calendarUpFallback) } + // Keep the registered identity stable while its implementation reads the + // current loaded-day state. A new lambda keyed by firstFocusableDayIndex + // would otherwise leave the shell holding the pre-load callback until this + // screen disposes. + val calendarUpFallbackRegistration: (Boolean) -> Boolean = + remember { { isRepeat -> currentCalendarUpFallback.value(isRepeat) } } + DisposableEffect(onContentUpFallbackChanged, calendarUpFallbackRegistration) { + onContentUpFallbackChanged?.invoke(calendarUpFallbackRegistration) + onDispose { onContentUpFallbackChanged?.invoke(calendarUpFallbackRegistration) } } // The day-snap is the ONLY vertical scroller: with the default spec the diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt index f84da8a3f..838092e9c 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt @@ -62,14 +62,13 @@ internal sealed class TvHeroFactToken { } /** - * Full-bleed cinematic hero for the Android TV detail screen. Mirrors the - * tvOS `TVDetailHero` 1:1. + * Full-bleed cinematic hero for the Android TV detail screen. Structurally + * mirrors tvOS `TVDetailHero`. * * Layout = a `ZStack(bottomLeading)`: a near-full-viewport backdrop, a * 4-stop horizontal darkening on the left, a soft vertical fade into the * rail body at the bottom, then the bottom-anchored editorial + action - * column on the left. The "Starring …" credit floats upper-right as its own - * trailing overlay (tvOS `starringOverlay`), NOT inside the editorial column. + * column on the left. * * Apple sizes the hero relative to the viewport (`heroHeight = 980` of a * 1080-pt canvas ≈ 0.907×), so we compute the height as a fraction of the @@ -92,7 +91,7 @@ internal fun TvDetailHero( overview: String?, tagline: String?, factsLine: List, - starringText: String?, + directorText: String?, actions: @Composable () -> Unit, modifier: Modifier = Modifier, // Optional description-translation affordance (Apple tvOS parity), @@ -157,36 +156,6 @@ internal fun TvDetailHero( ), ) - // "Starring …" floats in the upper-right of the hero, right-aligned — - // tvOS `.overlay(alignment: .trailing)` + `.padding(.bottom, heroHeight - // * 0.45)` (the bottom padding on the vertically-centered overlay - // pushes the credit into the top-right region). 2-line limit; sized up - // from the raw tvOS ~2x mapping (24pt → 12sp) per design review, with - // maxWidth widened to match so casts don't ellipsize sooner. - starringText?.takeIf { it.isNotBlank() }?.let { line -> - Text( - text = line, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 20.sp, - color = Color.White.copy(alpha = 0.8f), - textAlign = TextAlign.End, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = TextStyle( - shadow = Shadow( - color = Color.Black.copy(alpha = 0.55f), - offset = Offset(0f, 2f), - blurRadius = 6f, - ), - ), - modifier = Modifier - .align(Alignment.CenterEnd) - .padding(end = Spacing.safeArea, bottom = heroHeight * 0.45f) - .widthIn(max = 280.dp), - ) - } - // Reserve and pin the action cluster before measuring the editorial // column. The hero can therefore keep the fixed tvOS viewport framing // without letting tall copy collapse visible-but-focusable controls. @@ -204,6 +173,7 @@ internal fun TvDetailHero( overview = overview, tagline = tagline, factsLine = factsLine, + directorText = directorText, contentMaxWidth = contentMaxWidth, verticalSpacing = editorialSpacing, collapsedSynopsisLines = collapsedSynopsisLines, @@ -237,6 +207,7 @@ private fun EditorialColumn( overview: String?, tagline: String?, factsLine: List, + directorText: String?, contentMaxWidth: androidx.compose.ui.unit.Dp, verticalSpacing: androidx.compose.ui.unit.Dp, collapsedSynopsisLines: Int, @@ -269,6 +240,20 @@ private fun EditorialColumn( } translation?.invoke() + // Quiet "Directed by …" credit between the synopsis and the facts row. + // 14sp = the ten-foot metadata floor; the 0.62 alpha keeps it reading + // as a credit rather than another synopsis line. + directorText?.takeIf { it.isNotBlank() }?.let { line -> + Text( + text = line, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + color = Color.White.copy(alpha = 0.62f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (factsLine.isNotEmpty()) { FactsRow(tokens = factsLine) } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt index 2febb2743..37d340701 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt @@ -77,12 +77,6 @@ internal object TvDetailMetadata { return tokens } - fun starringText(detail: ItemDetail): String? { - val names = detail.cast.take(3).map { it.name.trim() }.filter { it.isNotEmpty() } - if (names.isEmpty()) return null - return "Starring ${names.joinToString(", ")}" - } - private fun typeLabel(detail: ItemDetail): String = when { isAudiobookItemType(detail.type) -> "Audiobook" else -> when (detail.type.lowercase()) { diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt index 5cd26424a..9d5631369 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt @@ -91,17 +91,18 @@ import androidx.tv.material3.Text import org.prairieserver.prairie.audiobook.AudioPlaybackTrack import org.prairieserver.prairie.audiobook.AudiobookTimeline import org.prairieserver.prairie.audiobook.buildAudiobookTimeline +import org.prairieserver.prairie.common.ui.movieDirectorCredit import org.prairieserver.prairie.model.audiobook.AudiobookNarration import org.prairieserver.prairie.model.catalog.EpisodeListItem import org.prairieserver.prairie.model.catalog.FileVersion import org.prairieserver.prairie.model.catalog.ItemDetail +import org.prairieserver.prairie.model.catalog.isSpecialsForDisplay import org.prairieserver.prairie.model.catalog.VersionChapter import org.prairieserver.prairie.model.catalog.isAudiobookItemType import org.prairieserver.prairie.model.ebook.MediaRelatedItem import org.prairieserver.prairie.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED import org.prairieserver.prairie.model.section.SectionItem import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode import org.prairieserver.prairie.tv.ui.components.TvDialogOption import org.prairieserver.prairie.tv.ui.components.TvErrorScreen import org.prairieserver.prairie.tv.ui.components.TvHeroActionPill @@ -445,7 +446,7 @@ private fun TvDetailContent( preferredQuality = state.preferredQuality, selectedFileId = heroSelectedFileId, ), - starringText = TvDetailMetadata.starringText(detail), + directorText = movieDirectorCredit(detail), translation = translationSlot, actions = { HeroActionRow( @@ -794,7 +795,8 @@ private fun HeroActionRow( val hasWatchTogether = CLIENT_WATCH_TOGETHER_SURFACE_ENABLED && !isAudiobookItemType(detail.type) val hasSuggestionTarget = detail.type in setOf("movie", "episode") || nextUp != null - val canSuggestToRoom = activeRoom != null && hasSuggestionTarget + val canSuggestToRoom = + CLIENT_WATCH_TOGETHER_SURFACE_ENABLED && activeRoom != null && hasSuggestionTarget val hasOverflowMenu = hasOverflowNavigation || hasWatchTogether || canSuggestToRoom // Version set + selection state driving the selector row / Play file id. @@ -1086,13 +1088,7 @@ private fun HeroActionRow( isBusy = watchTogetherState.isBusy, error = watchTogetherState.error, onHost = { watchTogetherViewModel.createRoom(playContentId, playFileId) }, - onHostVote = { - watchTogetherViewModel.createRoom( - playContentId, - playFileId, - RoomSelectionMode.Vote, - ) - }, + onHostVote = watchTogetherViewModel::createEmptyVoteRoom, onJoin = { watchTogetherViewModel.clearError() joinCodeOpen = true @@ -1290,10 +1286,15 @@ private fun currentEpisodeRailContentId(detail: ItemDetail, state: TvItemDetailU else -> null } -private fun episodeEyebrowLabel(detail: ItemDetail, state: TvItemDetailUiState): String { - state.selectedSeason?.takeIf { it > 0 }?.let { return "Season $it" } +internal fun episodeEyebrowLabel(detail: ItemDetail, state: TvItemDetailUiState): String { + state.seasons + .firstOrNull { it.seasonNumber == state.selectedSeason } + ?.let { season -> + return if (season.isSpecialsForDisplay()) "Specials" else "Season ${season.seasonNumber}" + } + state.selectedSeason?.let { return if (it == 0) "Specials" else "Season $it" } if (detail.type == "episode") { - detail.seasonNumber?.takeIf { it > 0 }?.let { return "Season $it" } + detail.seasonNumber?.let { return if (it == 0) "Specials" else "Season $it" } } return "This Season" } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailViewModel.kt index 706200a41..2701b6786 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailViewModel.kt @@ -3,6 +3,7 @@ package org.prairieserver.prairie.tv.ui.screens.detail import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.common.settings.PlayerSettingsStore +import org.prairieserver.prairie.domain.settings.ProfileSettingsController import org.prairieserver.prairie.model.catalog.BrowseItem import org.prairieserver.prairie.model.catalog.CastMember import org.prairieserver.prairie.model.catalog.EpisodeListItem @@ -11,7 +12,7 @@ import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.model.catalog.LeafItemUserData import org.prairieserver.prairie.model.catalog.Season import org.prairieserver.prairie.model.catalog.isAudiobookItemType -import org.prairieserver.prairie.model.catalog.sortedForDisplay +import org.prairieserver.prairie.model.catalog.initialSeasonDisplayPlan import org.prairieserver.prairie.model.playback.combinedSubtitleSelectionIndexes import org.prairieserver.prairie.playback.SUBTITLE_OFF_FINGERPRINT import org.prairieserver.prairie.playback.audioTrackFingerprint @@ -90,10 +91,11 @@ data class TvItemDetailUiState( val preferredQuality: String = "auto", // Cascaded subtitle preferences that annotate the selector row's Auto // preview ("Auto - " / "Auto - None") so it previews the SAME track - // the player would auto-select. ItemDetail (unlike WatchDetail) carries no - // per-item effective_* fields, so these are sourced from the active profile - // (matching the profile fallback in TvVideoPlaybackStarter); showForced - // defaults to true when unset, as the player state does. + // the player would auto-select. Resolved canonically — see + // [loadSubtitlePreferences]; the profile columns are only the fallback for + // a server that cannot resolve. `preferredSubtitleLanguage` null is "no + // preference" and "" is "no subtitles"; showForced defaults to true when + // unset, as the player state does. val preferredSubtitleLanguage: String? = null, val subtitleMode: String? = null, val showForcedSubtitles: Boolean = true, @@ -199,6 +201,7 @@ class TvItemDetailViewModel( private val personalDataRepository: PersonalDataRepository, private val playerSettingsStore: PlayerSettingsStore, private val profileRepository: ProfileRepository, + private val profileSettings: ProfileSettingsController, metadataAiRepository: org.prairieserver.prairie.repository.MetadataAiRepository, private val contentId: String, private val userItemState: UserItemStatePort = NoOpUserItemStatePort, @@ -237,16 +240,38 @@ class TvItemDetailViewModel( /** * Loads the cascaded subtitle preferences that annotate the selector row's - * Auto preview. This screen loads an [ItemDetail], which carries no per-item - * `effective_*` subtitle fields (only [org.prairieserver.prairie.model.catalog.WatchDetail] - * does), so — unlike [TvVideoPlaybackStarter], which reads the WatchDetail - * effective fields first — these come purely from the active profile - * (`subtitle_language` / `subtitle_mode` / `show_forced_subtitles`), the - * same fallback the starter drops to. showForced defaults to true when unset, - * matching the player state and the starter's `?: true`. + * Auto preview. + * + * These resolve canonically, through the same [ProfileSettingsController] + * the settings screen writes with. The `user_profiles` columns + * `GET /profiles` serves are NOT equivalent: the settings screen writes + * `playback.subtitle_language` / `subtitle_mode` / `show_forced_subtitles` + * at `scope=profile` and the server does not mirror a canonical write back + * into those columns, so reading them here previewed the preference the + * user had *before* their last edit while + * [org.prairieserver.prairie.tv.ui.screens.player.TvVideoPlaybackStarter] — which + * reads WatchDetail's server-resolved `effective_*` fields — played the new + * one. The columns stay as the fallback for a server that cannot resolve + * canonically. showForced defaults to true when unset, matching the player + * state and the starter's `?: true`. */ private fun loadSubtitlePreferences() { viewModelScope.launch { + val resolved = runCatching { profileSettings.load() }.getOrNull()?.snapshot + if (resolved != null) { + _uiState.update { + it.copy( + // The snapshot spells "no preference" as "", the Auto + // preview spells it as null (it reads "" as "no subs", + // matching the profile column, which the server omits + // when empty). Translate rather than leak the wrong one. + preferredSubtitleLanguage = resolved.subtitleLanguage.ifBlank { null }, + subtitleMode = resolved.subtitleMode, + showForcedSubtitles = resolved.showForcedSubtitles, + ) + } + return@launch + } val profile = runCatching { profileRepository.getActiveProfile() }.getOrNull() _uiState.update { it.copy( @@ -345,7 +370,7 @@ class TvItemDetailViewModel( -> detail.seriesId?.takeIf { it.isNotBlank() }?.let { seriesId -> loadSeasons( seriesContentId = seriesId, - preferredSeasonNumber = detail.seasonNumber?.takeIf { it > 0 }, + preferredSeasonNumber = detail.seasonNumber, ) } } @@ -657,20 +682,17 @@ class TvItemDetailViewModel( _uiState.update { it.copy(seasonsLoading = true) } when (val r = catalogRepository.getSeasons(seriesContentId)) { is ApiResult.Success -> { - val seasons = r.data.seasons.sortedForDisplay() - val selectedSeason = preferredSeasonNumber - ?.let { seasonNumber -> seasons.firstOrNull { it.seasonNumber == seasonNumber } } - val firstRegular = selectedSeason - ?: seasons.firstOrNull { !it.isSpecials } - ?: seasons.firstOrNull() + val plan = r.data.seasons.initialSeasonDisplayPlan(preferredSeasonNumber) _uiState.update { it.copy( seasonsLoading = false, - seasons = seasons, - selectedSeason = firstRegular?.seasonNumber, + seasons = plan.seasons, + selectedSeason = plan.selectedSeasonNumber, ) } - if (firstRegular != null) loadEpisodes(seriesContentId, firstRegular.seasonNumber) + plan.episodeRequestSeasonNumber?.let { seasonNumber -> + loadEpisodes(seriesContentId, seasonNumber) + } } else -> _uiState.update { it.copy(seasonsLoading = false) } } @@ -698,7 +720,7 @@ class TvItemDetailViewModel( // Cancel any in-flight episode load so a slower response for a // previously-selected season can't overwrite episodes/next-up for the // season the user is now on (rapid season switches / the initial - // firstRegular load racing a route-driven season load). + // selected-season load racing a route-driven season load). episodeLoadJob?.cancel() episodeLoadJob = viewModelScope.launch { if (!quiet) _uiState.update { it.copy(episodesLoading = true) } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSeasonPicker.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSeasonPicker.kt index 0c78dd9b1..1fcfe0947 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSeasonPicker.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSeasonPicker.kt @@ -45,6 +45,7 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import org.prairieserver.prairie.model.catalog.Season +import org.prairieserver.prairie.model.catalog.isSpecialsForDisplay import org.prairieserver.prairie.tv.ui.theme.TvControlCorner /** @@ -182,7 +183,7 @@ private fun TvSeasonChip( contentAlignment = Alignment.Center, ) { Text( - text = season.displayLabel(), + text = tvSeasonPickerLabel(season), style = MaterialTheme.typography.titleLarge.copy( fontSize = 16.sp, fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, @@ -192,7 +193,7 @@ private fun TvSeasonChip( } } -private fun Season.displayLabel(): String { - if (isSpecials) return "Specials" - return title?.takeIf { it.isNotBlank() } ?: "Season $seasonNumber" +internal fun tvSeasonPickerLabel(season: Season): String { + if (season.isSpecialsForDisplay()) return "Specials" + return season.title?.takeIf { it.isNotBlank() } ?: "Season ${season.seasonNumber}" } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/home/TvHomeScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/home/TvHomeScreen.kt index abd6d5cbb..4f3222a1f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/home/TvHomeScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/home/TvHomeScreen.kt @@ -59,7 +59,7 @@ fun TvHomeScreen( firstRowFocusRequester: FocusRequester? = null, firstRowContainerFocusRequester: FocusRequester? = null, shouldRefreshOnResume: () -> Boolean = { true }, - onContentUpFallbackChanged: (((() -> Boolean)?) -> Unit)? = null, + onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, viewModel: HomeViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() @@ -180,7 +180,7 @@ private fun TvHomeContent( detailReturnCardFocusRequester: FocusRequester?, firstRowFocusRequester: FocusRequester?, firstRowContainerFocusRequester: FocusRequester?, - onContentUpFallbackChanged: (((() -> Boolean)?) -> Unit)?, + onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)?, onSetWatched: (String, Boolean) -> Unit = { _, _ -> }, onToggleFavorite: (String, Boolean) -> Unit = { _, _ -> }, onToggleWatchlist: (String, Boolean) -> Unit = { _, _ -> }, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailScreen.kt index 04c5e8e8c..234ee0e14 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailScreen.kt @@ -98,7 +98,7 @@ fun TvLibraryDetailScreen( // section-apply effect on it (not just initialSection) makes re-committing // the SAME pill re-apply the section instead of being a silent no-op. sectionRequestNonce: Int = 0, - onContentUpFallbackChanged: (((() -> Boolean)?) -> Unit)? = null, + onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, viewModel: TvLibraryDetailViewModel = koinViewModel( key = "library-$libraryId", parameters = { parametersOf(libraryId, libraryTitle, libraryType) }, @@ -164,6 +164,7 @@ fun TvLibraryDetailScreen( onRetry = viewModel::retryBrowse, onInitialContentFocus = onInitialContentFocus, showAlphabetRail = true, + onContentUpFallbackChanged = onContentUpFallbackChanged, ) TvLibraryTab.RecentlyAdded -> LibraryTab( state = state, @@ -241,7 +242,7 @@ private fun RecommendedTab( onRetry: () -> Unit, onInitialContentFocus: () -> Unit, focusRequest: Int, - onContentUpFallbackChanged: (((() -> Boolean)?) -> Unit)?, + onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)?, ) { val rows = remember(state.sections) { state.sections.filter { !it.featured && it.items.isNotEmpty() } @@ -309,7 +310,7 @@ private fun LibraryTab( onSortKeySelected: (TvLibrarySortOption) -> Unit = {}, onFacetSelectionApplied: (TvCatalogFacetSelection) -> Unit = {}, /** Shell hook for overriding D-pad Up while the A–Z rail holds focus. */ - onContentUpFallbackChanged: (((() -> Boolean)?) -> Unit)? = null, + onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, onClearAudiobookGroup: (() -> Unit)? = null, ) { val restoredGridItemFocusRequester = remember { FocusRequester() } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportCluster.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportCluster.kt index 1ec14638c..778f9c61c 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportCluster.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportCluster.kt @@ -65,7 +65,7 @@ fun TvPlayerTransportCluster( onOpenQuickSubtitles: () -> Unit, /** * Non-null only when there is a next episode to show. Mirrors - * silo-apple#86: the automatic trigger fires at the credits, and this lets + * prairie-apple#86: the automatic trigger fires at the credits, and this lets * a viewer who is already done reach it early. */ onUpNext: (() -> Unit)? = null, 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..b31537214 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 @@ -481,7 +481,7 @@ data class AiTranslateUiState( * final progress flushing, and session stop. Intro auto-skip and player notices * are exposed as separate flows for the screen to consume. * - * Playback itself still goes through [org.prairieserver.prairie.common.player.PrairiePlayerFactory] + + * Playback itself still goes through [org.prairieserver.prairie.common.player.SiloPlayerFactory] + * [PlaybackSessionManager]. The ViewModel receives track info from the * screen (via [onTracksChanged]) because ExoPlayer is owned by the * composable. @@ -734,7 +734,7 @@ class TvPlayerViewModel( val isScrubbing: Boolean = false, val scrubPreviewSec: Double = 0.0, // Sidecar subtitle URLs from the playback session — passed into - // [PrairiePlayerFactory.createMediaSource] so the player loads them + // [SiloPlayerFactory.createMediaSource] so the player loads them // as text tracks (the stream manifest doesn't reference these). val subtitleUrls: List = emptyList(), // Server media file id for the active version — required by the @@ -1019,16 +1019,16 @@ class TvPlayerViewModel( * Per-profile audio delay in ms, ±500 clamp. Sourced from * [PlayerSettingsStore.audioSyncMsFlow]; mirrored into the active * [org.prairieserver.prairie.common.player.audio.DelayAudioProcessor] by - * [org.prairieserver.prairie.common.player.PrairiePlaybackService] (E T3). + * [org.prairieserver.prairie.common.player.SiloPlaybackService] (E T3). * The HUD Audio pane reads this for its delay stepper. */ val audioDelayMs: StateFlow = playerSettingsStore.audioSyncMsFlow .stateIn(viewModelScope, SharingStarted.Eagerly, 0) /** - * Per-profile subtitle delay in ms, ±500 clamp. Sourced from + * Per-device subtitle delay in ms, ±10000 clamp. Sourced from * [PlayerSettingsStore.subtitleSyncMsFlow]; mirrored into the active * [org.prairieserver.prairie.common.player.subtitle.SubtitleOffsetHolder] by - * [org.prairieserver.prairie.common.player.PrairiePlaybackService] (A.3f T2). + * [org.prairieserver.prairie.common.player.SiloPlaybackService] (A.3f T2). * The HUD Subtitles pane reads this for its delay stepper. */ val subtitleDelayMs: StateFlow = playerSettingsStore.subtitleSyncMsFlow @@ -1124,7 +1124,7 @@ class TvPlayerViewModel( // Reduce the analytics listener's event stream into the HUD's Stats // snapshot. The listener is a process-wide singleton shared with - // PrairiePlaybackService; we just subscribe — no extra registration. + // SiloPlaybackService; we just subscribe — no extra registration. viewModelScope.launch { playbackAnalytics.events.collect { event -> _uiState.update { it.copy(stats = reducePlayerStats(it.stats, event)) } @@ -3650,14 +3650,14 @@ class TvPlayerViewModel( } /** - * HUD Subtitles pane stepper handler. Coerced to ±500ms in the store; the + * HUD Subtitles pane stepper handler. Coerced to ±10000ms in the store; the * service binding (A.3f T2) picks up the new value and pushes it into the * shared [org.prairieserver.prairie.common.player.subtitle.SubtitleOffsetHolder] - * (forcing a flush via `seekTo(currentPosition)` so the change applies - * mid-playback by dropping already-buffered cues). + * while reparsing the current media item so the change applies to already- + * buffered cues. */ fun onSubtitleDelayChanged(delayMs: Int) { - viewModelScope.launch { playerSettingsStore.setSubtitleSyncMsFor(contentId, delayMs) } + viewModelScope.launch { playerSettingsStore.setSubtitleSyncMs(delayMs) } } // ---- Sleep timer setters --------------------------------------------------- diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomSyncController.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomSyncController.kt index 8161d6a2b..7069c6ca7 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomSyncController.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomSyncController.kt @@ -205,8 +205,16 @@ class TvRoomSyncController( while (isActive) { val state = viewModel.uiState.value val sessionId = state.sessionId + val deliveryKey = deliveryLatch.keyOrNull( + repository.connectionState.value, + sessionId, + ) val now = monotonicMs() - if (sessionId != null && + if (deliveryKey != null && + deliveryLatch.isServerAttached( + deliveryKey, + repository.roomDeliveryEcho.value, + ) && tvShouldEmitStateReport( now, lastReportMs, @@ -217,7 +225,7 @@ class TvRoomSyncController( ) { lastReportMs = now repository.stateReport( - sessionId = sessionId, + sessionId = deliveryKey.playbackSessionId, positionSeconds = state.position, isPaused = state.isPaused, ) @@ -243,7 +251,14 @@ class TvRoomSyncController( if (playbackState != RoomPlaybackState.Waiting || key == null) { return@collectLatest } - while (!deliveryLatch.isAttached(key)) delay(10) + while ( + !deliveryLatch.isServerAttached( + key, + repository.roomDeliveryEcho.value, + ) + ) { + delay(10) + } while (isActive && deliveryLatch.needsReadiness(key, buffering)) { val currentState = viewModel.uiState.value val delivered = if (buffering) { diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvVideoPlaybackStarter.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvVideoPlaybackStarter.kt index a150866e2..320f089dc 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvVideoPlaybackStarter.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvVideoPlaybackStarter.kt @@ -19,6 +19,7 @@ import org.prairieserver.prairie.model.playback.applyResumeRewind import org.prairieserver.prairie.model.playback.buildPlaybackSubtitleChoices import org.prairieserver.prairie.model.playback.resolvePlaybackStartRequestPosition import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.playback.orNullIfBlank import org.prairieserver.prairie.playback.selectPlaybackVersion import org.prairieserver.prairie.repository.CatalogRepository import org.prairieserver.prairie.repository.ProfileRepository @@ -133,6 +134,11 @@ class TvVideoPlaybackStarter( subtitleTrackIndex = request.subtitleTrackIndex, qualityPreference = playbackQualityIntent, startPosition = startRequestPosition, + // The bandwidth half of the quality choice. The server + // applies the cap only from what the request carries, so + // sending the resolution alone lets a capped preset stream + // at the bandwidth the user explicitly declined. + maxBitrateKbps = playerSettingsStore.maxBitrateKbpsFlow.first(), deferPublication = true, ) ) { @@ -236,10 +242,14 @@ class TvVideoPlaybackStarter( plannedTracks = resolved.subtitleUrls.orEmpty(), ), preferredAudioLanguage = preferredAudioLanguage ?: activeProfile?.language, - preferredTextLanguage = watchDetail.effectiveSubtitleLanguage - ?: activeProfile?.subtitleLanguage, - preferredSubtitleMode = watchDetail.effectiveSubtitleMode - ?: activeProfile?.subtitleMode, + // Blank normalizes to null on every rung: a canonical row + // holding JSON null ("no preference") arrives here as a + // present-but-empty string, and TV auto-selection reads a + // non-null blank language as an explicit "subtitles off". + preferredTextLanguage = watchDetail.effectiveSubtitleLanguage.orNullIfBlank() + ?: activeProfile?.subtitleLanguage.orNullIfBlank(), + preferredSubtitleMode = watchDetail.effectiveSubtitleMode.orNullIfBlank() + ?: activeProfile?.subtitleMode.orNullIfBlank(), showForcedSubtitles = watchDetail.effectiveShowForcedSubtitles ?: activeProfile?.showForcedSubtitles ?: true, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvCreateProfileScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvCreateProfileScreen.kt index 20328e476..dff897acf 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvCreateProfileScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvCreateProfileScreen.kt @@ -41,7 +41,7 @@ fun TvCreateProfileScreen( maxContentRating = state.maxContentRating, pinEnabled = state.pinEnabled, pin = state.pin, - qualityPreference = state.qualityPreference, + qualityPreference = null, subtitleMode = state.subtitleMode, pinHelper = "4-Digit PIN", submitLabel = "Create Profile", @@ -58,7 +58,7 @@ fun TvCreateProfileScreen( onContentRatingSelected = viewModel::onContentRatingSelected, onPinToggled = viewModel::onPinToggled, onPinChanged = viewModel::onPinChanged, - onQualitySelected = viewModel::onQualitySelected, + onQualitySelected = {}, onSubtitleModeSelected = viewModel::onSubtitleModeSelected, onSubmit = viewModel::onCreateClick, onCancel = onNavigateBack, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvCreateProfileViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvCreateProfileViewModel.kt index 92a4c23f0..532a2d796 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvCreateProfileViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvCreateProfileViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.model.profile.CreateProfileRequest import org.prairieserver.prairie.model.profile.Profile -import org.prairieserver.prairie.model.profile.canonicalProfileQualityPreference import org.prairieserver.prairie.model.profile.hasProfileNamed import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.repository.ProfileRepository @@ -28,7 +27,6 @@ data class TvCreateProfileUiState( val maxContentRating: String? = null, val pinEnabled: Boolean = false, val pin: String = "", - val qualityPreference: String? = null, val subtitleMode: String? = null, val isLoading: Boolean = false, val error: String? = null, @@ -117,12 +115,6 @@ class TvCreateProfileViewModel( _uiState.update { it.copy(pin = filtered, error = null) } } - fun onQualitySelected(quality: String) { - _uiState.update { - it.copy(qualityPreference = canonicalProfileQualityPreference(quality)) - } - } - fun onSubtitleModeSelected(mode: String) { _uiState.update { // Send the explicit "off" wire value rather than null so an "Off" @@ -156,7 +148,6 @@ class TvCreateProfileViewModel( pin = if (current.pinEnabled) current.pin else null, isChild = if (current.isChild) true else null, maxContentRating = current.maxContentRating, - qualityPreference = current.qualityPreference, subtitleMode = current.subtitleMode, ) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileFormOptions.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileFormOptions.kt index a8cbb477b..d786c07e3 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileFormOptions.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileFormOptions.kt @@ -15,33 +15,6 @@ val TV_QUALITY_OPTIONS = listOf("Auto", "4K", "1080p", "720p", "480p") /** Available subtitle modes. "Off" maps to a null mode. */ val TV_SUBTITLE_MODES = listOf("Off", "Default", "Always", "Forced Only") -/** - * Pre-defined emoji avatars. Mirrors `AvatarOptions.emojis` on the phone so the - * picker offers the same set; the server stores the chosen string verbatim. - */ -val TV_AVATAR_EMOJIS = listOf( - "😀", // grinning face - "😎", // smiling face with sunglasses - "🤓", // nerd face - "🥸", // disguised face - "👾", // alien monster - "🐱", // cat face - "🐶", // dog face - "🦊", // fox - "🦁", // lion - "🐻", // bear - "🐧", // penguin - "🦉", // owl - "🌟", // glowing star - "🌈", // rainbow - "🎨", // artist palette - "🎬", // clapper board - "🎵", // musical note - "🚀", // rocket - "🌍", // globe - "🍓", // strawberry -) - /** Display label for a stored subtitle-mode value (e.g. "forced_only" -> "Forced Only"). */ fun subtitleModeLabel(stored: String?): String = stored?.replace("_", " ")?.split(" ") diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequest.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequest.kt new file mode 100644 index 000000000..eef62ff96 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequest.kt @@ -0,0 +1,56 @@ +package org.prairieserver.prairie.tv.ui.screens.recommendations + +enum class SavedListSelection { + Watchlist, + Favorites, +} + +data class TvForYouEntryRequest( + val sequence: Int = 0, + val selection: SavedListSelection? = null, +) { + fun next(selection: SavedListSelection?): TvForYouEntryRequest = + TvForYouEntryRequest(sequence = sequence + 1, selection = selection) + + fun nextForTopLevelForYou(): TvForYouEntryRequest = next(null) +} + +internal data class AppliedForYouSelection( + val selection: SavedListSelection?, + val lastAppliedSequence: Int, + val appliedRequest: Boolean, +) + +internal fun applyForYouEntryRequest( + currentSelection: SavedListSelection?, + lastAppliedSequence: Int, + request: TvForYouEntryRequest, +): AppliedForYouSelection = + if (request.sequence <= lastAppliedSequence) { + AppliedForYouSelection( + selection = currentSelection, + lastAppliedSequence = lastAppliedSequence, + appliedRequest = false, + ) + } else { + AppliedForYouSelection( + selection = request.selection, + lastAppliedSequence = request.sequence, + appliedRequest = true, + ) + } + +internal suspend fun requestForYouEntryFocus( + selection: SavedListSelection?, + awaitFrame: suspend () -> Unit, + requestForYou: () -> Boolean, + requestWatchlist: () -> Boolean, + requestFavorites: () -> Boolean, +): Boolean { + awaitFrame() + return when (selection) { + null -> requestForYou() + SavedListSelection.Watchlist -> requestWatchlist() + SavedListSelection.Favorites -> requestFavorites() + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridge.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridge.kt new file mode 100644 index 000000000..ac403f27c --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridge.kt @@ -0,0 +1,17 @@ +package org.prairieserver.prairie.tv.ui.screens.recommendations + +internal suspend fun requestRecommendationRowFocus( + requestRowContainer: () -> Boolean, + awaitFrame: suspend () -> Unit, + requestFirstCard: () -> Boolean, +): Boolean { + if (!requestRowContainer()) return false + awaitFrame() + requestFirstCard() + return true +} + +internal fun shouldBridgeRecommendationsDown( + showingRecommendations: Boolean, + hasVisibleRecommendations: Boolean, +): Boolean = showingRecommendations && hasVisibleRecommendations diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt index aef85d1b1..16a044bdc 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt @@ -1,6 +1,8 @@ package org.prairieserver.prairie.tv.ui.screens.recommendations import androidx.compose.foundation.background +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,18 +15,24 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -47,11 +55,11 @@ import org.prairieserver.prairie.tv.ui.screens.personal.TvFavoritesInline import org.prairieserver.prairie.tv.ui.screens.personal.TvWatchlistInline import org.prairieserver.prairie.tv.ui.shell.TvTopMenuLayout import org.prairieserver.prairie.tv.ui.theme.Spacing +import org.prairieserver.prairie.tv.ui.theme.TvSmoothBringIntoViewSpec import org.prairieserver.prairie.tv.ui.util.visibleOnTv import org.prairieserver.prairie.viewmodel.RecommendationsViewModel import org.koin.compose.viewmodel.koinViewModel -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.foundation.layout.width +import kotlinx.coroutines.launch private val RecommendationsFilterBandHeight = 52.dp @@ -61,18 +69,86 @@ private val RecommendationsFilterBandHeight = 52.dp * (rows down the page) minus the featured hero — the discover API returns * section-style rows, not a hero card. */ -@OptIn(ExperimentalTvMaterial3Api::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalTvMaterial3Api::class) @Composable fun TvRecommendationsScreen( onItemClick: (contentId: String) -> Unit, onInitialContentFocus: () -> Unit = {}, focusRequest: Int = 0, + entryRequest: TvForYouEntryRequest = TvForYouEntryRequest(), viewModel: RecommendationsViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() val visibleSections = remember(state.sections) { state.sections.visibleOnTv() } + val forYouFocusRequester = remember { FocusRequester() } val watchlistFocusRequester = remember { FocusRequester() } - var savedListSelection by remember { mutableStateOf(null) } + val favoritesFocusRequester = remember { FocusRequester() } + val firstRecommendationRowFocusRequester = remember { FocusRequester() } + val firstRecommendationCardFocusRequester = remember { FocusRequester() } + val focusBridgeScope = rememberCoroutineScope() + // rememberSaveable, not remember: opening an item disposes this screen's + // composition, and a plain remember would re-initialise from + // entryRequest.selection on the way back. Top-level For You entry carries + // selection = null, so returning from a Watchlist item did not merely + // forget the list — it actively reselected the recommendations feed. + // + // lastAppliedEntrySequence must survive with it. Resetting it to 0 makes + // the LaunchedEffect below treat the unchanged entry request as new and + // re-apply its selection, which reintroduces the same jump even once the + // selection itself is saved. + val recommendationsListState = rememberLazyListState() + var savedListSelection by rememberSaveable { mutableStateOf(entryRequest.selection) } + var lastAppliedEntrySequence by rememberSaveable { mutableIntStateOf(0) } + val moveIntoRecommendations: () -> Boolean = { + if ( + !shouldBridgeRecommendationsDown( + showingRecommendations = savedListSelection == null, + hasVisibleRecommendations = visibleSections.isNotEmpty(), + ) + ) { + false + } else { + focusBridgeScope.launch { + requestRecommendationRowFocus( + requestRowContainer = { + runCatching { firstRecommendationRowFocusRequester.requestFocus() } + .getOrDefault(false) + }, + awaitFrame = { withFrameNanos { } }, + requestFirstCard = { + runCatching { firstRecommendationCardFocusRequester.requestFocus() } + .getOrDefault(false) + }, + ) + } + true + } + } + + LaunchedEffect(entryRequest.sequence) { + val applied = applyForYouEntryRequest( + currentSelection = savedListSelection, + lastAppliedSequence = lastAppliedEntrySequence, + request = entryRequest, + ) + savedListSelection = applied.selection + lastAppliedEntrySequence = applied.lastAppliedSequence + if (applied.appliedRequest) { + requestForYouEntryFocus( + selection = applied.selection, + awaitFrame = { withFrameNanos { } }, + requestForYou = { + runCatching { forYouFocusRequester.requestFocus() }.getOrDefault(false) + }, + requestWatchlist = { + runCatching { watchlistFocusRequester.requestFocus() }.getOrDefault(false) + }, + requestFavorites = { + runCatching { favoritesFocusRequester.requestFocus() }.getOrDefault(false) + }, + ) + } + } // Match tvOS: recommendations remain the landing content when available; // an empty successful response defaults to the inline Watchlist fallback. @@ -85,8 +161,17 @@ fun TvRecommendationsScreen( // The saved-list shortcuts are the stable first row in every state. Focus // Watchlist once per entry, matching tvOS, without letting later refreshes // pull focus away from the user's current position. - var initialFocusRequested by remember { mutableStateOf(false) } - var lastAppliedFocusRequest by remember { mutableStateOf(-1) } + // rememberSaveable for the same reason as the selection above: these guard + // a once-per-entry focus grab, and as plain `remember` they reset when an + // item detail disposes this composition. The effect then re-fires on the + // way back and slams focus onto the Watchlist pill while the feed is still + // scrolled where the viewer left it — which is the shell's documented + // anti-pattern ("fired LaunchedEffects in each screen that imperatively + // re-focused index 0 — defeating the restorer"). Saved, the grab stays a + // genuine once-per-entry action and the shell's content restorer is left + // to put focus back where it was. + var initialFocusRequested by rememberSaveable { mutableStateOf(false) } + var lastAppliedFocusRequest by rememberSaveable { mutableStateOf(-1) } LaunchedEffect(focusRequest) { if (initialFocusRequested && focusRequest == lastAppliedFocusRequest) return@LaunchedEffect runCatching { watchlistFocusRequester.requestFocus() } @@ -169,41 +254,56 @@ fun TvRecommendationsScreen( onClick = viewModel::loadRecommendations, contentPadding = PaddingValues(horizontal = 32.dp, vertical = 12.dp), ) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) Text("Check again", style = MaterialTheme.typography.labelLarge) } } } } else -> { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - verticalArrangement = Arrangement.spacedBy(18.dp), - contentPadding = PaddingValues( - top = TvTopMenuLayout.contentTopInset + RecommendationsFilterBandHeight, - bottom = 24.dp, - ), + CompositionLocalProvider( + LocalBringIntoViewSpec provides TvSmoothBringIntoViewSpec, ) { - items( - items = visibleSections, - key = { it.id }, - contentType = { "recommendation-section-row" }, - ) { section -> - TvMediaRow( - title = section.title, - items = section.items, - onItemClick = onItemClick, - style = TvRowStyle.Poster, - ) + LazyColumn( + // Hoisted above the `when` so it is not discarded when a + // refresh briefly flips this branch to loading/empty and + // back — that is what dropped the reader at the top of the + // feed after opening an item. + state = recommendationsListState, + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + verticalArrangement = Arrangement.spacedBy(18.dp), + contentPadding = PaddingValues( + top = TvTopMenuLayout.contentTopInset + RecommendationsFilterBandHeight, + bottom = 24.dp, + ), + ) { + itemsIndexed( + items = visibleSections, + key = { _, section -> section.id }, + contentType = { _, _ -> "recommendation-section-row" }, + ) { index, section -> + TvMediaRow( + title = section.title, + items = section.items, + onItemClick = onItemClick, + style = TvRowStyle.Poster, + firstItemFocusRequester = firstRecommendationCardFocusRequester + .takeIf { index == 0 }, + rowContainerFocusRequester = firstRecommendationRowFocusRequester + .takeIf { index == 0 }, + onDirectionUp = if (index == 0) { + { + runCatching { forYouFocusRequester.requestFocus() } + .getOrDefault(false) + } + } else { + null + }, + ) + } + item { Spacer(modifier = Modifier.height(8.dp)) } } - item { Spacer(modifier = Modifier.height(8.dp)) } } } } @@ -238,6 +338,8 @@ fun TvRecommendationsScreen( icon = Icons.Outlined.AutoAwesome, variant = TvPillVariant.Hollow, selected = savedListSelection == null, + focusRequester = forYouFocusRequester, + onDirectionDown = moveIntoRecommendations, heightOverride = 32.dp, horizontalPaddingOverride = 13.dp, iconSizeOverride = 10.dp, @@ -259,6 +361,7 @@ fun TvRecommendationsScreen( variant = TvPillVariant.Hollow, selected = savedListSelection == SavedListSelection.Watchlist, focusRequester = watchlistFocusRequester, + onDirectionDown = moveIntoRecommendations, heightOverride = 32.dp, horizontalPaddingOverride = 13.dp, iconSizeOverride = 10.dp, @@ -279,6 +382,8 @@ fun TvRecommendationsScreen( icon = Icons.Filled.Favorite, variant = TvPillVariant.Hollow, selected = savedListSelection == SavedListSelection.Favorites, + focusRequester = favoritesFocusRequester, + onDirectionDown = moveIntoRecommendations, heightOverride = 32.dp, horizontalPaddingOverride = 13.dp, iconSizeOverride = 10.dp, @@ -297,8 +402,3 @@ fun TvRecommendationsScreen( } } } - -private enum class SavedListSelection { - Watchlist, - Favorites, -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsScreen.kt index 4c130e382..3ad625a9a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsScreen.kt @@ -76,26 +76,24 @@ import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text +import org.prairieserver.prairie.model.settings.LanguageOptions +import org.prairieserver.prairie.domain.settings.ProfileSettingsController +import org.prairieserver.prairie.model.settings.QualityPresets +import org.prairieserver.prairie.model.settings.SettingKeys import org.prairieserver.prairie.model.settings.SubtitleBackgroundStylePreset import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset import org.prairieserver.prairie.model.settings.SubtitlePositionPreset import org.prairieserver.prairie.model.settings.pointSize import org.prairieserver.prairie.tv.BuildConfig -import org.prairieserver.prairie.tv.data.preferences.PlaybackQuality import org.prairieserver.prairie.tv.data.preferences.SubtitleMode import org.prairieserver.prairie.tv.ui.screens.player.TvSubtitleAppearanceOptions import org.prairieserver.prairie.tv.ui.screens.settings.diagnostics.TvDiagnosticsViewModel import org.prairieserver.prairie.tv.ui.theme.FocusedContainer import org.prairieserver.prairie.tv.ui.theme.FocusedContent import org.prairieserver.prairie.tv.ui.theme.Spacing -import org.prairieserver.prairie.update.changelogUrlOrNull -import org.prairieserver.prairie.update.latestVersionLabel -import org.prairieserver.prairie.update.releaseUrlOrNull -import org.prairieserver.prairie.update.statusLabel import org.koin.compose.viewmodel.koinViewModel import kotlinx.coroutines.delay -import androidx.compose.ui.platform.LocalUriHandler /** * TV Settings — a tvOS-style split rail/detail surface modeled on @@ -200,7 +198,7 @@ fun TvSettingsScreen( onNavigateToDiagnostics = onNavigateToDiagnostics, onRequestSignOut = { showSignOutConfirm = true }, onNavigateToAdmin = onNavigateToAdmin, - onQualityChanged = viewModel::onPlaybackQualityChanged, + onQualityPresetSelected = viewModel::onQualityPresetSelected, onAudioLanguageChanged = viewModel::onAudioLanguageChanged, onAutoPlayNextChanged = viewModel::onAutoPlayNextChanged, onAutoSkipIntroChanged = viewModel::onAutoSkipIntroChanged, @@ -301,7 +299,8 @@ private fun SettingsSplitLayout( onNavigateToDiagnostics: () -> Unit, onRequestSignOut: () -> Unit, onNavigateToAdmin: () -> Unit, - onQualityChanged: (PlaybackQuality) -> Unit, + /** Receives a [QualityPresets] preset id. */ + onQualityPresetSelected: (String) -> Unit, onAudioLanguageChanged: (String) -> Unit, onAutoPlayNextChanged: (Boolean) -> Unit, onAutoSkipIntroChanged: (Boolean) -> Unit, @@ -369,7 +368,7 @@ private fun SettingsSplitLayout( onShowAudiobooksTabChanged = onShowAudiobooksTabChanged, onManageServers = onManageServers, onNavigateToDiagnostics = onNavigateToDiagnostics, - onQualityChanged = onQualityChanged, + onQualityPresetSelected = onQualityPresetSelected, onAudioLanguageChanged = onAudioLanguageChanged, onAutoPlayNextChanged = onAutoPlayNextChanged, onAutoSkipIntroChanged = onAutoSkipIntroChanged, @@ -486,7 +485,7 @@ private fun SettingsRail( onFocused = { railActionHasFocus = true }, ) Text( - text = "Prairie ${state.appVersionName.ifBlank { BuildConfig.VERSION_NAME }}", + text = "Prairie ${BuildConfig.VERSION_NAME}", style = MaterialTheme.typography.bodySmall.copy( fontFamily = FontFamily.Monospace, fontSize = 14.sp, @@ -638,7 +637,8 @@ private fun SettingsDetailPane( onShowAudiobooksTabChanged: (Boolean) -> Unit, onManageServers: () -> Unit, onNavigateToDiagnostics: () -> Unit, - onQualityChanged: (PlaybackQuality) -> Unit, + /** Receives a [QualityPresets] preset id. */ + onQualityPresetSelected: (String) -> Unit, onAudioLanguageChanged: (String) -> Unit, onAutoPlayNextChanged: (Boolean) -> Unit, onAutoSkipIntroChanged: (Boolean) -> Unit, @@ -699,7 +699,7 @@ private fun SettingsDetailPane( TvSettingsCategory.Playback -> TvPlaybackSettingsPane( state = state, firstFocusRequester = detailFocusRequester, - onQualityChanged = onQualityChanged, + onQualityPresetSelected = onQualityPresetSelected, onAudioLanguageChanged = onAudioLanguageChanged, onAutoPlayNextChanged = onAutoPlayNextChanged, onAutoSkipIntroChanged = onAutoSkipIntroChanged, @@ -784,7 +784,8 @@ private fun TvGeneralSettingsPane( private fun TvPlaybackSettingsPane( state: TvSettingsViewModel.UiState, firstFocusRequester: FocusRequester, - onQualityChanged: (PlaybackQuality) -> Unit, + /** Receives a [QualityPresets] preset id. */ + onQualityPresetSelected: (String) -> Unit, onAudioLanguageChanged: (String) -> Unit, onAutoPlayNextChanged: (Boolean) -> Unit, onAutoSkipIntroChanged: (Boolean) -> Unit, @@ -798,6 +799,13 @@ private fun TvPlaybackSettingsPane( onResetPlaybackOverrides: () -> Unit, ) { var activePicker by remember { mutableStateOf(null) } + val audioLanguages = remember(state.audioLanguage, state.audioLanguageSuggestions) { + LanguageOptions.options( + key = SettingKeys.PLAYBACK_AUDIO_LANGUAGE, + currentValue = state.audioLanguage, + runtimeValues = state.audioLanguageSuggestions, + ) + } LazyColumn( modifier = Modifier.fillMaxSize(), @@ -808,13 +816,16 @@ private fun TvPlaybackSettingsPane( SettingsGroup(title = "Streaming") { SettingsValueRow( label = "Quality", - value = state.playbackQuality.label, + value = QualityPresets.describe(state.qualityResolution, state.maxBitrateKbps), onClick = { activePicker = PlaybackPicker.Quality }, focusRequester = firstFocusRequester, ) SettingsValueRow( label = "Audio Language", - value = audioLanguageLabel(state.audioLanguage), + value = LanguageOptions.label( + state.audioLanguage, + SettingKeys.PLAYBACK_AUDIO_LANGUAGE, + ), onClick = { activePicker = PlaybackPicker.AudioLanguage }, ) // tvOS TVPlaybackSettingsPane STREAMING parity: Dolby Vision @@ -889,19 +900,23 @@ private fun TvPlaybackSettingsPane( } when (activePicker) { + // The picker offers presets; a stored pair no preset covers (set + // through the API, or left by a legacy compound value) selects + // nothing rather than silently highlighting the wrong entry. PlaybackPicker.Quality -> TvSettingsPickerSheet( title = "Quality", - options = PlaybackQuality.values().map { PickerOption(it.name, it.label) }, - selectedId = state.playbackQuality.name, + options = QualityPresets.ALL.map { PickerOption(it.id, it.label) }, + selectedId = QualityPresets.presetFor(state.qualityResolution, state.maxBitrateKbps)?.id + ?: "", onSelect = { id -> - PlaybackQuality.values().firstOrNull { it.name == id }?.let(onQualityChanged) + onQualityPresetSelected(id) activePicker = null }, onDismiss = { activePicker = null }, ) PlaybackPicker.AudioLanguage -> TvSettingsPickerSheet( title = "Audio Language", - options = AudioLanguages.map { PickerOption(it.first, it.second) }, + options = audioLanguages.map { PickerOption(it.first, it.second) }, selectedId = state.audioLanguage, onSelect = { onAudioLanguageChanged(it); activePicker = null }, onDismiss = { activePicker = null }, @@ -965,12 +980,37 @@ private fun TvSubtitleSettingsPane( var activePicker by remember { mutableStateOf(null) } var showResetConfirmation by remember { mutableStateOf(false) } val appearance = state.subtitleAppearance + val subtitleLanguages = remember( + state.subtitleLanguage, + state.subtitleLanguageSuggestions, + ) { + LanguageOptions.options( + key = SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, + currentValue = state.subtitleLanguage, + runtimeValues = state.subtitleLanguageSuggestions, + ) + } + val metadataLanguages = remember( + state.metadataLanguage, + state.metadataLanguageSuggestions, + ) { + LanguageOptions.options( + key = SettingKeys.CATALOG_METADATA_LANGUAGE, + currentValue = state.metadataLanguage, + runtimeValues = state.metadataLanguageSuggestions, + ) + } LazyColumn( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = Spacing.xxxl), ) { + if (state.settingsAvailability == + ProfileSettingsController.Availability.SERVER_UPGRADE_REQUIRED + ) { + item { TvSettingsUpgradeRequiredNotice() } + } item { SettingsGroup(title = "Profile") { SettingsValueRow( @@ -981,13 +1021,19 @@ private fun TvSubtitleSettingsPane( ) SettingsValueRow( label = "Language", - value = subtitleLanguageLabel(state.subtitleLanguage), + value = LanguageOptions.label( + state.subtitleLanguage, + SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, + ), onClick = { activePicker = SubtitlePicker.Language }, ) if (metadataLanguageEnabled) { SettingsValueRow( label = "Metadata Language", - value = subtitleLanguageLabel(state.metadataLanguage), + value = LanguageOptions.label( + state.metadataLanguage, + SettingKeys.CATALOG_METADATA_LANGUAGE, + ), onClick = { activePicker = SubtitlePicker.MetadataLanguage }, ) } @@ -1105,14 +1151,14 @@ private fun TvSubtitleSettingsPane( ) SubtitlePicker.Language -> TvSettingsPickerSheet( title = "Language", - options = SubtitleLanguages.map { PickerOption(it.first, it.second) }, + options = subtitleLanguages.map { PickerOption(it.first, it.second) }, selectedId = state.subtitleLanguage, onSelect = { onSubtitleLanguageChanged(it); activePicker = null }, onDismiss = { activePicker = null }, ) SubtitlePicker.MetadataLanguage -> TvSettingsPickerSheet( title = "Metadata Language", - options = SubtitleLanguages.map { PickerOption(it.first, it.second) }, + options = metadataLanguages.map { PickerOption(it.first, it.second) }, selectedId = state.metadataLanguage, onSelect = { onMetadataLanguageChanged(it); activePicker = null }, onDismiss = { activePicker = null }, @@ -1306,7 +1352,6 @@ private fun TvServerSettingsPane( onManageServers: () -> Unit, onNavigateToDiagnostics: () -> Unit, ) { - val uriHandler = LocalUriHandler.current LazyColumn( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp), @@ -1343,29 +1388,7 @@ private fun TvServerSettingsPane( } item { SettingsGroup(title = "About") { - SettingsInfoRow( - label = "Version", - value = state.appVersionName.ifBlank { BuildConfig.VERSION_NAME }, - ) - SettingsInfoRow( - label = "Update status", - value = state.appUpdateStatus.statusLabel(), - ) - state.appUpdateStatus.latestVersionLabel()?.let { latest -> - SettingsInfoRow(label = "Latest version", value = latest) - } - state.appUpdateStatus.changelogUrlOrNull()?.let { url -> - SettingsActionRow( - label = "Changelog", - onClick = { runCatching { uriHandler.openUri(url) } }, - ) - } - state.appUpdateStatus.releaseUrlOrNull()?.let { url -> - SettingsActionRow( - label = "View update", - onClick = { runCatching { uriHandler.openUri(url) } }, - ) - } + SettingsInfoRow(label = "Version", value = BuildConfig.VERSION_NAME) } } } @@ -2007,6 +2030,25 @@ private fun SettingsInfoRow(label: String, value: String, singleLine: Boolean = } /** Non-focusable explanatory footer below a settings group (tvOS `TVSettingsFooter`). */ +/** + * Shown when the connected server predates the canonical settings API. + * + * The failure mode this replaces was a settings pane that looked normal but + * saved nothing: the profile preferences resolve to nothing, so the rows show + * defaults and every edit goes nowhere with no explanation. Playback is + * unaffected — it runs from this device's own settings. + */ +@Composable +private fun TvSettingsUpgradeRequiredNotice() { + SettingsGroup(title = "Server Update Needed") { + SettingsFooterText( + text = "This server is too old to store profile settings. Subtitle and metadata " + + "preferences below will not save until it is updated. Playback still works " + + "using this Android TV's own settings.", + ) + } +} + @Composable private fun SettingsFooterText(text: String) { Text( @@ -2054,42 +2096,6 @@ private val PassOutThresholdOptions = listOf(0, 2, 3, 4, 5) // Up-Next prompt timing (seconds before end; 0 = at end). Mirrors tvOS. private val NextUpPromptOptions = listOf(0, 10, 30, 60, 120) -// Audio-language options mirror the phone: the stored value IS the display -// name (Default => "" locally), persisted to playerSettingsStore.audioLanguage. -private val AudioLanguages = listOf( - "" to "Default", - "English" to "English", - "Spanish" to "Spanish", - "French" to "French", - "German" to "German", - "Japanese" to "Japanese", - "Korean" to "Korean", - "Chinese" to "Chinese", - "Portuguese" to "Portuguese", - "Italian" to "Italian", - "Russian" to "Russian", -) - -private val SubtitleLanguages = listOf( - "" to "Off", - "en" to "English", - "es" to "Spanish", - "fr" to "French", - "de" to "German", - "ja" to "Japanese", - "ko" to "Korean", - "zh" to "Chinese", - "pt" to "Portuguese", - "it" to "Italian", - "ru" to "Russian", -) - -private fun audioLanguageLabel(wire: String): String = - AudioLanguages.firstOrNull { it.first == wire }?.second ?: "Default" - -private fun subtitleLanguageLabel(wire: String): String = - SubtitleLanguages.firstOrNull { it.first == wire }?.second ?: "Off" - private fun resumeRewindLabel(seconds: Int): String = if (seconds <= 0) "Off" else "${seconds}s" diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsViewModel.kt index 006f78ac7..ae2e586c6 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsViewModel.kt @@ -9,7 +9,8 @@ import org.prairieserver.prairie.common.settings.PlayerSettingsStore import org.prairieserver.prairie.model.admin.shouldShowClientAdminSurface import org.prairieserver.prairie.model.auth.User import org.prairieserver.prairie.model.auth.isActingAdmin -import org.prairieserver.prairie.model.profile.UpdateProfileRequest +import org.prairieserver.prairie.domain.settings.ProfileSettingsController +import org.prairieserver.prairie.model.settings.QualityPresets import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.settings.SubtitleBackgroundStylePreset import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset @@ -20,11 +21,8 @@ import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.repository.AuthRepository import org.prairieserver.prairie.repository.ProfileRepository import org.prairieserver.prairie.tv.data.preferences.LegacyTvPrefsMigration -import org.prairieserver.prairie.tv.data.preferences.PlaybackQuality import org.prairieserver.prairie.tv.data.preferences.SubtitleMode import org.prairieserver.prairie.tv.data.preferences.SubtitleSize -import org.prairieserver.prairie.update.AppUpdateChecker -import org.prairieserver.prairie.update.AppUpdateStatus import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -37,9 +35,12 @@ import kotlinx.coroutines.launch /** * ViewModel for the TV settings screen. Server-managed device settings * flow exclusively through [PlayerSettingsStore] (mirror of iOS - * `PlayerSettings.shared`); profile-level subtitle prefs still go via - * [profileRepository]. [LegacyTvPrefsMigration] runs the one-time legacy - * `tv_prefs` → server import on first boot (sentinel-gated no-op after). + * `PlayerSettings.shared`); profile-level preferences go through + * [ProfileSettingsController], which writes them as canonical settings at + * `scope=profile` rather than as columns on the profile endpoint — the same + * path the phone uses, so the two screens cannot drift. + * [LegacyTvPrefsMigration] runs the one-time legacy `tv_prefs` → server + * import on first boot (sentinel-gated no-op after). * * Sign-out and switch-profile operations emit a one-shot [NavAction] * signal that the screen collects and forwards to the top-level NavHost. @@ -53,9 +54,8 @@ class TvSettingsViewModel( private val libraryPlaybackPrefsStore: LibraryPlaybackPrefsStore, private val overlayPrefsStore: OverlayPrefsStore, private val legacyTvPrefsMigration: LegacyTvPrefsMigration, + private val profileSettings: ProfileSettingsController, private val tvLibraryScopeStore: org.prairieserver.prairie.tv.data.preferences.TvLibraryScopeStore? = null, - private val appUpdateChecker: AppUpdateChecker, - private val appVersionName: String, ) : ViewModel() { enum class NavAction { SIGNED_OUT, SWITCH_PROFILE } @@ -69,12 +69,25 @@ class TvSettingsViewModel( val profileAvatar: String? = null, val serverUrl: String = "", val serverName: String = "", - val playbackQuality: PlaybackQuality = PlaybackQuality.Auto, + // Whether this server serves the canonical settings API at all. When + // it reports SERVER_UPGRADE_REQUIRED the pane explains that instead of + // showing rows whose edits go nowhere; playback is unaffected. + val settingsAvailability: ProfileSettingsController.Availability = + ProfileSettingsController.Availability.UNKNOWN, + // Quality is two orthogonal values behind one picker: + // playback.preferred_quality (resolution) and + // playback.max_bitrate_kbps (bandwidth; null = uncapped). The preset + // table is shared with the phone, so the two cannot drift. + val qualityResolution: String = QualityPresets.RESOLUTION_AUTO, + val maxBitrateKbps: Int? = null, val subtitleMode: SubtitleMode = SubtitleMode.Auto, val subtitleLanguage: String = "", + val subtitleLanguageSuggestions: List = emptyList(), // Metadata AI: preferred description/metadata language ("" = server default). val metadataLanguage: String = "", + val metadataLanguageSuggestions: List = emptyList(), val audioLanguage: String = "", + val audioLanguageSuggestions: List = emptyList(), val subtitleSize: SubtitleSize = SubtitleSize.Medium, val showForcedSubtitles: Boolean = true, // Full subtitle appearance + whether the device-scoped override is on. @@ -100,31 +113,15 @@ class TvSettingsViewModel( // Client admin is hidden for now even when the server would accept acting-admin. val adminVisible: Boolean = false, val navAction: NavAction? = null, - val appVersionName: String = "", - val appUpdateStatus: AppUpdateStatus = AppUpdateStatus.Checking, ) - private val _uiState = MutableStateFlow(UiState(appVersionName = appVersionName)) + private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow = _uiState.asStateFlow() init { loadUser() loadSettings() observePlayerSettings() - checkForAppUpdate() - } - - private fun checkForAppUpdate() { - viewModelScope.launch { - _uiState.update { - it.copy( - appVersionName = appVersionName, - appUpdateStatus = AppUpdateStatus.Checking, - ) - } - val status = appUpdateChecker.check(appVersionName) - _uiState.update { it.copy(appUpdateStatus = status) } - } } /** @@ -199,32 +196,89 @@ class TvSettingsViewModel( // mirrors them into _uiState. playerSettingsStore.refreshFromServer() - when (val profileResult = profileRepository.getActiveProfileResult()) { - is ApiResult.Success -> { - val profile = profileResult.data - _uiState.update { - it.copy( - subtitleMode = SubtitleMode.fromWire(profile.subtitleMode), - subtitleLanguage = profile.subtitleLanguage.orEmpty(), - metadataLanguage = profile.preferredMetadataLanguage.orEmpty(), - showForcedSubtitles = profile.showForcedSubtitles ?: true, - ) - } - } - is ApiResult.Error, is ApiResult.NetworkError -> Unit + loadProfileSettings() + } + } + + /** + * Resolves the profile-scoped preferences through the canonical settings + * API, and records whether this server speaks it at all. + * + * On [ProfileSettingsController.Availability.SERVER_UPGRADE_REQUIRED] the + * values are left alone and the Subtitles pane explains why — rendering + * rows whose edits silently go nowhere is the failure this replaces. + * Playback keeps running from the device-scoped store. + */ + fun loadProfileSettings() { + viewModelScope.launch { + val result = profileSettings.load() + _uiState.update { state -> + val snapshot = result.snapshot ?: return@update state.copy( + settingsAvailability = result.availability, + ) + state.copy( + settingsAvailability = result.availability, + subtitleMode = SubtitleMode.fromWire(snapshot.subtitleMode), + subtitleLanguage = snapshot.subtitleLanguage, + metadataLanguage = snapshot.metadataLanguage, + showForcedSubtitles = snapshot.showForcedSubtitles, + audioLanguageSuggestions = snapshot.audioLanguageSuggestions, + subtitleLanguageSuggestions = snapshot.subtitleLanguageSuggestions, + metadataLanguageSuggestions = snapshot.metadataLanguageSuggestions, + ) + } + } + } + + /** + * Replaces the optimistic values with what the server actually resolves. + * + * A successful PUT stores the authored value; it does not make it + * effective. Policy can narrow it, and a device-scoped row for the same key + * outranks the profile row these setters write — so the screen would + * otherwise show a preference playback is not using. Skipped when a newer + * edit for the *same* field landed while the round trip was in flight, + * which the optimistic rollbacks guard the same way. + */ + private fun applyResolved( + snapshot: ProfileSettingsController.Snapshot?, + edited: String, + fieldOf: (ProfileSettingsController.Snapshot) -> String, + ) { + if (snapshot == null) return + if (fieldOf(snapshot) == edited) { + _uiState.update { + it.copy( + audioLanguageSuggestions = snapshot.audioLanguageSuggestions, + subtitleLanguageSuggestions = snapshot.subtitleLanguageSuggestions, + metadataLanguageSuggestions = snapshot.metadataLanguageSuggestions, + ) } + return + } + _uiState.update { state -> + state.copy( + subtitleMode = SubtitleMode.fromWire(snapshot.subtitleMode), + subtitleLanguage = snapshot.subtitleLanguage, + metadataLanguage = snapshot.metadataLanguage, + showForcedSubtitles = snapshot.showForcedSubtitles, + audioLanguageSuggestions = snapshot.audioLanguageSuggestions, + subtitleLanguageSuggestions = snapshot.subtitleLanguageSuggestions, + metadataLanguageSuggestions = snapshot.metadataLanguageSuggestions, + ) } } /** * Mirror device-scoped flows into UI state. The store is the single * source of truth — this just projects to the TV-specific UI types - * (PlaybackQuality, SubtitleSize). + * (the two quality axes, SubtitleSize). */ private fun observePlayerSettings() { viewModelScope.launch { combine( playerSettingsStore.preferredQualityFlow, + playerSettingsStore.maxBitrateKbpsFlow, playerSettingsStore.autoPlayNextFlow, playerSettingsStore.autoSkipIntroFlow, playerSettingsStore.autoSkipCreditsFlow, @@ -235,23 +289,28 @@ class TvSettingsViewModel( ) { values -> @Suppress("UNCHECKED_CAST") val quality = values[0] as String + val bitrate = values[1] as Int? @Suppress("UNCHECKED_CAST") - val autoPlay = values[1] as Boolean + val autoPlay = values[2] as Boolean @Suppress("UNCHECKED_CAST") - val skipIntro = values[2] as Boolean + val skipIntro = values[3] as Boolean @Suppress("UNCHECKED_CAST") - val skipCredits = values[3] as Boolean + val skipCredits = values[4] as Boolean @Suppress("UNCHECKED_CAST") - val appearance = values[4] as SubtitleAppearance + val appearance = values[5] as SubtitleAppearance @Suppress("UNCHECKED_CAST") - val audioLang = values[5] as String - val rewind = values[6] as Int - val threshold = values[7] as Int - Snapshot(quality, autoPlay, skipIntro, skipCredits, appearance, audioLang, rewind, threshold) + val audioLang = values[6] as String + val rewind = values[7] as Int + val threshold = values[8] as Int + Snapshot( + quality, bitrate, autoPlay, skipIntro, skipCredits, + appearance, audioLang, rewind, threshold, + ) }.collect { snap -> _uiState.update { it.copy( - playbackQuality = PlaybackQuality.fromWire(snap.quality), + qualityResolution = snap.quality, + maxBitrateKbps = snap.maxBitrateKbps, autoPlayNext = snap.autoPlay, autoSkipIntro = snap.skipIntro, autoSkipCredits = snap.skipCredits, @@ -324,39 +383,66 @@ class TvSettingsViewModel( .ifBlank { url } } - fun onPlaybackQualityChanged(value: PlaybackQuality) { - viewModelScope.launch { playerSettingsStore.setPreferredQuality(value.wireValue) } + /** + * Applies one quality preset — the two axes it decomposes into. The + * compound legacy spellings ("1080p-high") are never written. + */ + fun onQualityPresetSelected(presetId: String) { + val preset = QualityPresets.byId(presetId) ?: return + viewModelScope.launch { + playerSettingsStore.setQuality(preset.resolution, preset.bitrateKbps) + } } + // The four profile preferences below are canonical settings written at + // scope=profile, one key per edit. They used to be named columns sent + // together on PUT /profiles/{id}, where one failed write reverted all + // three. Each applies optimistically and rolls back only if state still + // holds the value it wrote — a newer edit mid-request wins. + fun onSubtitleModeChanged(value: SubtitleMode) { - val previousState = _uiState.value + val previous = _uiState.value.subtitleMode _uiState.update { it.copy(subtitleMode = value) } - persistProfileSubtitleSettings(previousState) + viewModelScope.launch { + val result = profileSettings.setSubtitleMode(value.wireValue) + if (!result.succeeded) { + _uiState.update { + if (it.subtitleMode == value) it.copy(subtitleMode = previous) else it + } + } else { + applyResolved(result.snapshot, edited = value.wireValue) { it.subtitleMode } + } + } } fun onMetadataLanguageChanged(value: String) { val previous = _uiState.value.metadataLanguage _uiState.update { it.copy(metadataLanguage = value) } viewModelScope.launch { - when ( - profileRepository.updateActiveProfile( - UpdateProfileRequest(preferredMetadataLanguage = value.ifBlank { null }) - ) - ) { - is ApiResult.Success -> Unit - is ApiResult.Error, is ApiResult.NetworkError -> { - _uiState.update { current -> - if (current.metadataLanguage == value) current.copy(metadataLanguage = previous) else current - } + val result = profileSettings.setMetadataLanguage(value) + if (!result.succeeded) { + _uiState.update { current -> + if (current.metadataLanguage == value) current.copy(metadataLanguage = previous) else current } + } else { + applyResolved(result.snapshot, edited = value) { it.metadataLanguage } } } } fun onSubtitleLanguageChanged(value: String) { - val previousState = _uiState.value + val previous = _uiState.value.subtitleLanguage _uiState.update { it.copy(subtitleLanguage = value) } - persistProfileSubtitleSettings(previousState) + viewModelScope.launch { + val result = profileSettings.setSubtitleLanguage(value) + if (!result.succeeded) { + _uiState.update { + if (it.subtitleLanguage == value) it.copy(subtitleLanguage = previous) else it + } + } else { + applyResolved(result.snapshot, edited = value) { it.subtitleLanguage } + } + } } /** @@ -369,19 +455,25 @@ class TvSettingsViewModel( } fun onShowForcedSubtitlesChanged(enabled: Boolean) { - val previousState = _uiState.value + val previous = _uiState.value.showForcedSubtitles _uiState.update { it.copy(showForcedSubtitles = enabled) } - persistProfileSubtitleSettings(previousState) - } - - fun onSubtitleSizeChanged(value: SubtitleSize) { viewModelScope.launch { - val current = playerSettingsStore.subtitleAppearanceFlow.first() - val updated = current.copy(fontSize = value.toFontSizePreset()) - playerSettingsStore.setSubtitleAppearance(updated) + val result = profileSettings.setShowForcedSubtitles(enabled) + if (!result.succeeded) { + _uiState.update { + if (it.showForcedSubtitles == enabled) it.copy(showForcedSubtitles = previous) else it + } + } else { + applyResolved(result.snapshot, edited = enabled.toString()) { + it.showForcedSubtitles.toString() + } + } } } + fun onSubtitleSizeChanged(value: SubtitleSize) = + editAppearance { it.copy(fontSize = value.toFontSizePreset()) } + /** * Commit a full subtitle-appearance value (device-scoped). The Appearance * picker rows build [next] by copying the current appearance and changing @@ -403,6 +495,10 @@ class TvSettingsViewModel( viewModelScope.launch { val current = playerSettingsStore.subtitleAppearanceFlow.first() playerSettingsStore.setSubtitleAppearance(transform(current)) + // The granular subtitle.* fields are client-local — the contract + // carries appearance as one object — so a per-field edit only + // reaches the server once it is projected into the composite. + playerSettingsStore.flushProjectedSubtitleAppearance() } } @@ -523,40 +619,6 @@ class TvSettingsViewModel( _uiState.update { it.copy(navAction = null) } } - private fun persistProfileSubtitleSettings(previousState: UiState) { - val state = _uiState.value - viewModelScope.launch { - when ( - profileRepository.updateActiveProfile( - UpdateProfileRequest( - subtitleLanguage = state.subtitleLanguage.ifBlank { null }, - subtitleMode = state.subtitleMode.wireValue, - showForcedSubtitles = state.showForcedSubtitles, - ) - ) - ) { - is ApiResult.Success -> Unit - is ApiResult.Error, is ApiResult.NetworkError -> { - _uiState.update { current -> - if ( - current.subtitleLanguage == state.subtitleLanguage && - current.subtitleMode == state.subtitleMode && - current.showForcedSubtitles == state.showForcedSubtitles - ) { - current.copy( - subtitleLanguage = previousState.subtitleLanguage, - subtitleMode = previousState.subtitleMode, - showForcedSubtitles = previousState.showForcedSubtitles, - ) - } else { - current - } - } - } - } - } - } - private fun SubtitleSize.toFontSizePreset(): SubtitleFontSizePreset = when (this) { SubtitleSize.Small -> SubtitleFontSizePreset.Small SubtitleSize.Medium -> SubtitleFontSizePreset.Medium @@ -575,6 +637,7 @@ class TvSettingsViewModel( private data class Snapshot( val quality: String, + val maxBitrateKbps: Int?, val autoPlay: Boolean, val skipIntro: Boolean, val skipCredits: Boolean, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestination.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestination.kt new file mode 100644 index 000000000..788a4181b --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestination.kt @@ -0,0 +1,20 @@ +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.tv.ui.navigation.TvRoute +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryTarget +import org.prairieserver.prairie.watchtogether.watchTogetherEntryTarget + +fun tvWatchTogetherDestination(room: RoomSnapshot): String = + when (watchTogetherEntryTarget(room)) { + WatchTogetherEntryTarget.Lobby -> + TvRoute.WatchTogetherLobby(room.roomId).route + WatchTogetherEntryTarget.Player -> + TvRoute.Player( + contentId = requireNotNull(room.selectedContentId), + fileId = room.selectedFileId, + roomId = room.roomId, + resumePositionSeconds = room.anchorPositionSeconds + .takeIf { it.isFinite() && it > 0.0 }, + ).route + } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt index 4fb4e3861..9e420a902 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt @@ -1,5 +1,6 @@ package org.prairieserver.prairie.tv.ui.screens.watchtogether +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background @@ -32,6 +33,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -106,11 +108,12 @@ fun TvWatchTogetherLobbyScreen( val room by viewModel.room.collectAsState() val suggestions by viewModel.suggestions.collectAsState() val closedReason by viewModel.roomClosedReason.collectAsState() + val context = LocalContext.current - // Role drives only the cosmetic header label; mutating controls (and the - // host's room-closing Back behaviour) gate on the server's per-recipient - // management capability so a demoted/grace-period host (selfRole still - // "host" but management revoked) doesn't see dead buttons. + // Role drives only the cosmetic header label; mutating controls gate on + // the server's per-recipient management capability so a demoted/ + // grace-period host (selfRole still "host" but management revoked) doesn't + // see dead buttons. val snapshot = room val isHostLabel = snapshot?.selfRole == MemberRole.Host val canManage = snapshot?.selfCanManageRoom == true @@ -145,14 +148,17 @@ fun TvWatchTogetherLobbyScreen( } } - // User-initiated leave just drops our own connection and exits — matching - // mobile, where leaving the lobby never closes the room. A host closes the - // room for everyone via the explicit "Close room" action (below), which - // stays on screen until the server's room_closed broadcast reactively backs - // us out. (Auto-closing here raced the closeRoom call against viewModelScope - // cancellation on dispose, so the room often never actually closed.) + LaunchedEffect(viewModel) { + viewModel.errors.collect { message -> + if (message.isNotBlank()) { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } + } + + // Browsing away retains the process-scoped room connection. Leaving is an + // explicit action below; hosts close the room for everyone via "Close room". BackHandler(enabled = true) { - viewModel.leave() onBack() } @@ -262,6 +268,17 @@ fun TvWatchTogetherLobbyScreen( verticalArrangement = Arrangement.spacedBy(20.dp), ) { if (snapshot != null) { + TvDialogActionRow( + title = "Browse titles", + onClick = onBack, + ) + TvDialogActionRow( + title = "Leave room", + onClick = { + viewModel.leave() + onBack() + }, + ) if (canManage) { // Selection mode is fixed at room creation — shown read-only. TvDialogCyclerRow( diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyViewModel.kt index 6ea581e66..b211ed207 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyViewModel.kt @@ -6,6 +6,8 @@ import org.prairieserver.prairie.model.watchtogether.PromoteSuggestionRequest import org.prairieserver.prairie.model.watchtogether.RoomSnapshot import org.prairieserver.prairie.model.watchtogether.Suggestion import org.prairieserver.prairie.model.watchtogether.UpdatePolicyRequest +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.errorMessage import org.prairieserver.prairie.repository.WatchTogetherRepository import org.prairieserver.prairie.watchtogether.RoomSession import kotlinx.coroutines.flow.SharingStarted @@ -40,26 +42,43 @@ class TvWatchTogetherLobbyViewModel( .stateIn(viewModelScope, SharingStarted.Eagerly, repository.suggestions.value) val roomClosedReason: StateFlow = repository.roomClosedReason .stateIn(viewModelScope, SharingStarted.Eagerly, repository.roomClosedReason.value) + val errors = repository.errors + + fun vote(suggestionId: String) = + launchOperation("Could not vote") { repository.vote(suggestionId) } + + fun unvote(suggestionId: String) = + launchOperation("Could not remove vote") { repository.unvote(suggestionId) } - fun vote(suggestionId: String) = viewModelScope.launch { repository.vote(suggestionId) } - fun unvote(suggestionId: String) = viewModelScope.launch { repository.unvote(suggestionId) } fun removeSuggestion(suggestionId: String) = - viewModelScope.launch { repository.deleteSuggestion(suggestionId) } + launchOperation("Could not remove suggestion") { + repository.deleteSuggestion(suggestionId) + } /** Host: promote a suggestion to the room selection (moves everyone to the player). */ fun promote(suggestionId: String) = - viewModelScope.launch { + launchOperation("Could not start suggestion") { repository.promoteSuggestion(PromoteSuggestionRequest(suggestionId = suggestionId)) } /** Host: change the guest-control policy. */ fun updatePolicy(guestControlPolicyWire: String) = - viewModelScope.launch { + launchOperation("Could not update room policy") { repository.updatePolicy(UpdatePolicyRequest(guestControlPolicy = guestControlPolicyWire)) } /** Host: close the room for everyone. */ - fun closeRoom() = viewModelScope.launch { repository.closeRoom() } + fun closeRoom() = launchOperation("Could not close room") { repository.closeRoom() } + + private fun launchOperation( + fallback: String, + operation: suspend () -> ApiResult, + ) = viewModelScope.launch { + val result = operation() + if (result !is ApiResult.Success) { + repository.reportDeliveryFailure(result.errorMessage(fallback)) + } + } /** * Leave the room: tear down our own WS and reset shared repo state. Matches diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntryDialog.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntryDialog.kt new file mode 100644 index 000000000..0eb4c8295 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntryDialog.kt @@ -0,0 +1,140 @@ +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import org.prairieserver.prairie.tv.ui.components.rememberTvDialogInitialFocus +import org.prairieserver.prairie.tv.ui.screens.player.TvDialogActionRow +import org.prairieserver.prairie.tv.ui.theme.DarkBackground +import org.prairieserver.prairie.watchtogether.canDismissRoomEntry + +enum class TvWatchTogetherMenuInitialAction { + Resume, + Host, +} + +fun tvWatchTogetherMenuInitialAction( + canResume: Boolean, +): TvWatchTogetherMenuInitialAction = + if (canResume) { + TvWatchTogetherMenuInitialAction.Resume + } else { + TvWatchTogetherMenuInitialAction.Host + } + +/** + * Focus-owning Watch Together entry popup launched from the authenticated + * profile dropdown. Resume is initially focused when a current room exists; + * otherwise Host owns initial focus. + */ +@Composable +fun TvWatchTogetherMenuEntryDialog( + canResume: Boolean, + isBusy: Boolean, + error: String?, + onResume: () -> Unit, + onHost: () -> Unit, + onJoin: () -> Unit, + onDismiss: () -> Unit, +) { + val resumeFocus = remember { FocusRequester() } + val hostFocus = remember { FocusRequester() } + val initialAction = tvWatchTogetherMenuInitialAction(canResume) + val initialFocus = when (initialAction) { + TvWatchTogetherMenuInitialAction.Resume -> resumeFocus + TvWatchTogetherMenuInitialAction.Host -> hostFocus + } + + Popup( + alignment = Alignment.Center, + onDismissRequest = { + if (canDismissRoomEntry(isBusy)) onDismiss() + }, + properties = PopupProperties( + focusable = true, + dismissOnBackPress = canDismissRoomEntry(isBusy), + dismissOnClickOutside = canDismissRoomEntry(isBusy), + clippingEnabled = false, + ), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(start = 36.dp, top = 50.dp, end = 36.dp, bottom = 42.dp), + contentAlignment = Alignment.Center, + ) { + val panelShape = RoundedCornerShape(14.dp) + Column( + modifier = Modifier + .width(340.dp) + .background(color = DarkBackground.copy(alpha = 0.68f), shape = panelShape) + .border(0.6.dp, Color.White.copy(alpha = 0.20f), panelShape) + .padding(horizontal = 14.dp, vertical = 14.dp) + .then(rememberTvDialogInitialFocus(initialFocus)), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "WATCH TOGETHER", + style = MaterialTheme.typography.labelMedium.copy( + fontSize = 16.sp, + letterSpacing = 1.1.sp, + fontWeight = FontWeight.Bold, + ), + color = Color.White.copy(alpha = 0.58f), + modifier = Modifier.padding(horizontal = 8.dp), + ) + + if (canResume) { + TvDialogActionRow( + title = "Resume current room", + enabled = !isBusy, + onClick = onResume, + modifier = Modifier.focusRequester(resumeFocus), + ) + } + + TvDialogActionRow( + title = if (isBusy) "Working…" else "Host a room", + enabled = !isBusy, + onClick = onHost, + modifier = Modifier.focusRequester(hostFocus), + ) + + TvDialogActionRow( + title = "Join by code", + enabled = !isBusy, + onClick = onJoin, + ) + + error?.let { message -> + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = Color(0xFFEF4444), + modifier = Modifier.padding(horizontal = 8.dp), + ) + } + } + } + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModel.kt index 6bc0f5358..c96438b1b 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModel.kt @@ -9,10 +9,14 @@ import org.prairieserver.prairie.model.watchtogether.RoomSnapshot import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.errorMessage -import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway +import org.prairieserver.prairie.watchtogether.resumableWatchTogetherRoom import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -22,8 +26,8 @@ import kotlinx.coroutines.launch * then surfaces a one-shot [UiState.result] [RoomSnapshot] the detail screen routes * on (host-with-selection → synced player, no selection → lobby). * - * The repository stores the room JWT internally on create/join and reads the - * active roomId from its own snapshot, so [WatchTogetherRepository.setSelection] + * The gateway stores the room JWT internally on create/join and reads the + * active roomId from its own snapshot, so [WatchTogetherEntryGateway.setSelection] * takes only the request. createRoom does NOT auto-select, so the host flow must * createRoom THEN setSelection(contentId, fileId) so the host lands on the player. * The ordering is safe because createRoom synchronously stores the snapshot before @@ -33,7 +37,7 @@ import kotlinx.coroutines.launch * Mirrors the mobile `WatchTogetherEntryViewModel` host()/joinByCode() shape. */ class TvWatchTogetherViewModel( - private val repository: WatchTogetherRepository, + private val gateway: WatchTogetherEntryGateway, ) : ViewModel() { data class UiState( @@ -45,6 +49,13 @@ class TvWatchTogetherViewModel( private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow = _uiState.asStateFlow() + val currentRoom: StateFlow = gateway.roomSnapshot + .map(::resumableWatchTogetherRoom) + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + resumableWatchTogetherRoom(gateway.roomSnapshot.value), + ) /** Host flow: create a room with this title pre-selected as the room selection. */ fun createRoom( @@ -52,26 +63,26 @@ class TvWatchTogetherViewModel( fileId: Int?, selectionMode: RoomSelectionMode = RoomSelectionMode.HostPick, ) { + if (selectionMode == RoomSelectionMode.Vote) { + createEmptyVoteRoom() + return + } if (_uiState.value.isBusy) return _uiState.update { it.copy(isBusy = true, error = null) } viewModelScope.launch { when ( - val created = repository.createRoom( + val created = gateway.createRoom( CreateRoomRequest(selectionMode = selectionMode.wire), ) ) { is ApiResult.Success -> { - if (selectionMode == RoomSelectionMode.Vote) { - finish(created.data.room) - return@launch - } // createRoom does NOT auto-select; set this title as the room - // selection so the host lands on the synced player. The repo + // selection so the host lands on the synced player. The gateway // already stored the snapshot synchronously, so setSelection // reads the right roomId/token. if (created.data.room.selectedContentId.isNullOrBlank()) { when ( - val sel = repository.setSelection( + val sel = gateway.setSelection( SetSelectionRequest(contentId = contentId, fileId = fileId), ) ) { @@ -89,13 +100,41 @@ class TvWatchTogetherViewModel( } } + /** Host flow for the menu action: create an empty vote room, then enter its lobby. */ + fun createEmptyVoteRoom() { + if (_uiState.value.isBusy) return + _uiState.update { it.copy(isBusy = true, error = null) } + viewModelScope.launch { + when ( + val created = gateway.createRoom( + CreateRoomRequest(selectionMode = RoomSelectionMode.Vote.wire), + ) + ) { + is ApiResult.Success -> finish(created.data.room) + is ApiResult.Error, is ApiResult.NetworkError -> + fail(created.errorMessage("Failed to create room")) + } + } + } + + /** Resume the valid room snapshot already held by the process session. */ + fun resumeCurrentRoom() { + if (_uiState.value.isBusy) return + val room = resumableWatchTogetherRoom(gateway.roomSnapshot.value) + if (room == null) { + fail("Current room is no longer available") + } else { + finish(room) + } + } + /** Join flow: resolve an invite code; the screen routes to player or lobby. */ fun joinRoom(code: String) { val trimmed = code.trim().uppercase() if (_uiState.value.isBusy || trimmed.isBlank()) return _uiState.update { it.copy(isBusy = true, error = null) } viewModelScope.launch { - when (val joined = repository.joinRoom(JoinRoomRequest(code = trimmed))) { + when (val joined = gateway.joinRoom(JoinRoomRequest(code = trimmed))) { is ApiResult.Success -> finish(joined.data.room) is ApiResult.Error, is ApiResult.NetworkError -> fail(joined.errorMessage("Could not join — check the code")) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvContentUpNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvContentUpNavigation.kt new file mode 100644 index 000000000..faa2a0373 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvContentUpNavigation.kt @@ -0,0 +1,10 @@ +package org.prairieserver.prairie.tv.ui.shell + +/** + * A held Up may traverse content, but it must not escape from the first + * content row into the top menu after focus movement has already failed. + */ +internal fun shouldRequestMenuAfterContentUp( + movedWithinContent: Boolean, + isRepeat: Boolean, +): Boolean = !movedWithinContent && !isRepeat diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt index cbc401690..5834d7584 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.filled.AutoAwesome -import androidx.compose.material.icons.filled.LiveTv import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Favorite @@ -54,6 +53,7 @@ import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusDirection @@ -101,7 +101,7 @@ import androidx.tv.material3.Text import androidx.lifecycle.compose.LifecycleResumeEffect import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.common.ui.components.isImageAvatar -import org.prairieserver.prairie.tv.ui.theme.PrairieOnSurface +import org.prairieserver.prairie.tv.ui.theme.SiloOnSurface import org.prairieserver.prairie.tv.ui.theme.DarkBackground import org.prairieserver.prairie.common.network.ServerReachabilityMonitor import org.prairieserver.prairie.common.network.ServerReachabilityStatus @@ -111,9 +111,10 @@ import org.prairieserver.prairie.common.ui.components.resolveAvatarUrl import org.prairieserver.prairie.model.catalog.BrowseItem import org.prairieserver.prairie.model.admin.shouldShowClientAdminSurface import org.prairieserver.prairie.model.auth.isActingAdmin -import org.prairieserver.prairie.model.feature.LiveTvFeatureStore +import org.prairieserver.prairie.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED import org.prairieserver.prairie.model.feature.RequestsFeatureStore import org.prairieserver.prairie.model.personal.UserLibrary +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.ServerRegistry import org.prairieserver.prairie.repository.AuthRepository @@ -145,17 +146,21 @@ import org.prairieserver.prairie.tv.ui.screens.personal.TvFavoritesScreen import org.prairieserver.prairie.tv.ui.screens.personal.TvHistoryScreen import org.prairieserver.prairie.tv.ui.screens.personal.TvWatchlistScreen import org.prairieserver.prairie.tv.ui.screens.recommendations.TvRecommendationsScreen +import org.prairieserver.prairie.tv.ui.screens.recommendations.SavedListSelection +import org.prairieserver.prairie.tv.ui.screens.recommendations.TvForYouEntryRequest import org.prairieserver.prairie.tv.ui.screens.requests.TvMyRequestsScreen import org.prairieserver.prairie.tv.ui.screens.requests.TvRequestDetailScreen -import org.prairieserver.prairie.tv.ui.screens.livetv.TvLiveTvPlayerScreen -import org.prairieserver.prairie.tv.ui.screens.livetv.TvLiveTvScreen import org.prairieserver.prairie.tv.ui.screens.requests.TvRequestsScreen import org.prairieserver.prairie.tv.ui.screens.search.TvSearchScreen import org.prairieserver.prairie.tv.ui.screens.settings.TvManageSessionsScreen import org.prairieserver.prairie.tv.ui.screens.settings.TvSettingsScreen +import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvJoinCodeDialog +import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherMenuEntryDialog +import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherViewModel import org.prairieserver.prairie.tv.ui.theme.TvSkyline import org.prairieserver.prairie.tv.ui.util.visibleOnTv import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel /** * Main authenticated TV shell. Mirrors `TVMainTabView` on tvOS: a content @@ -180,6 +185,7 @@ fun TvMainShell( onSwitchServer: () -> Unit, onPairDevice: () -> Unit, onPlayItem: (contentId: String, type: String?, resumePositionSeconds: Double?) -> Unit, + onOpenWatchTogether: (RoomSnapshot) -> Unit, onOpenPersonDetail: (personId: Long) -> Unit, ) { val nestedNav = rememberNavController() @@ -191,15 +197,26 @@ fun TvMainShell( val profileRepository: ProfileRepository = koinInject() val reachabilityMonitor: ServerReachabilityMonitor = koinInject() val requestsFeatureStore: RequestsFeatureStore = koinInject() - val liveTvFeatureStore: LiveTvFeatureStore = koinInject() val metadataAiFeatureStore: org.prairieserver.prairie.model.feature.MetadataAiFeatureStore = koinInject() val serverRegistry: ServerRegistry = koinInject() val reachabilityState by reachabilityMonitor.state.collectAsState() val requestsEnabled by requestsFeatureStore.isEnabled.collectAsState() - val liveTvEnabled by liveTvFeatureStore.isEnabled.collectAsState() val activeServerEntry by serverRegistry.activeEntry.collectAsState() val tvLibraryScopeStore: TvLibraryScopeStore = koinInject() val serverUrl = rememberProfileServerUrl() + val watchTogetherViewModel = koinViewModel() + val watchTogetherState by watchTogetherViewModel.uiState.collectAsState() + val currentWatchTogetherRoom by watchTogetherViewModel.currentRoom.collectAsState() + var watchTogetherEntryOpen by rememberSaveable { mutableStateOf(false) } + var watchTogetherJoinOpen by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(watchTogetherState.result) { + val room = watchTogetherState.result ?: return@LaunchedEffect + watchTogetherViewModel.consumeResult() + watchTogetherEntryOpen = false + watchTogetherJoinOpen = false + onOpenWatchTogether(room) + } // The raw list of libraries visible to this profile on TV, sorted by the // server's sort order (ebook-like libraries filtered out by visibleOnTv). @@ -287,8 +304,6 @@ fun TvMainShell( LaunchedEffect(activeServerEntry?.id, activeServerEntry?.profileId) { requestsFeatureStore.reset() requestsFeatureStore.refresh() - liveTvFeatureStore.reset() - liveTvFeatureStore.refresh() metadataAiFeatureStore.reset() metadataAiFeatureStore.refresh() } @@ -303,6 +318,15 @@ fun TvMainShell( // Opening an outer item-detail route pauses/removes this shell. Remember the // pending hand-back in the Main back-stack entry so it survives either form, // then re-enter the existing content focusRestorer when Main resumes. + // Two flags, deliberately. `restoreContentAfterDetail` says a detail return + // is pending for ANY root, and gates the resume claim below so focus lands + // back inside content instead of Compose's default search picking the top + // bar. `restoreHomeContentAfterDetail` additionally says it was the Home + // feed, which is the only root that attaches + // homeDetailReturnCardFocusRequester to its launch card — using that + // requester as the restorer fallback for a root that never attached it + // would point the restorer at a detached node. + var restoreContentAfterDetail by rememberSaveable { mutableStateOf(false) } var restoreHomeContentAfterDetail by rememberSaveable { mutableStateOf(false) } var suppressHomeRefreshAfterDetail by rememberSaveable { mutableStateOf(false) } var homeDetailReturnFocusRequest by remember { mutableIntStateOf(0) } @@ -321,7 +345,7 @@ fun TvMainShell( // yanks focus to a different card for a frame. var contentHasFocus by remember { mutableStateOf(false) } LifecycleResumeEffect(Unit) { - if (restoreHomeContentAfterDetail) { + if (restoreContentAfterDetail) { // Claim the content group synchronously during ON_RESUME, before // Compose's default search can briefly settle on the Home tab — // but only when the feed hasn't already claimed it. Claim BEFORE @@ -332,6 +356,7 @@ fun TvMainShell( } else { runCatching { !contentFocusRequester.requestFocus() }.getOrDefault(true) } + restoreContentAfterDetail = false restoreHomeContentAfterDetail = false homeDetailReturnFocusRequest++ } @@ -350,16 +375,26 @@ fun TvMainShell( suppressHomeRefreshAfterDetail = false } val openHomeItemDetail: (String) -> Unit = { contentId -> + restoreContentAfterDetail = true restoreHomeContentAfterDetail = true suppressHomeRefreshAfterDetail = true onOpenItemDetail(contentId) } - var contentUpFallback by remember { mutableStateOf<(() -> Boolean)?>(null) } + // Same hand-back for roots that render inside the shell but do not attach a + // launch-card requester (For You). Without this the shell never claims + // content focus on the return resume, so focus settles wherever Compose's + // default search lands — in practice the top bar — and the D-pad no longer + // drives the rows the viewer was just in. + val openContentItemDetail: (String) -> Unit = { contentId -> + restoreContentAfterDetail = true + onOpenItemDetail(contentId) + } + var contentUpFallback by remember { mutableStateOf<((Boolean) -> Boolean)?>(null) } // Feeds that registered the up-fallback slot, were superseded by a newer // feed, and are still awaiting their (now-stale) onDispose. Tracking them // lets us ignore that late dispose instead of nulling the entering feed's // registration. - val supersededContentUpFallbacks = remember { mutableSetOf<() -> Boolean>() } + val supersededContentUpFallbacks = remember { mutableSetOf<(Boolean) -> Boolean>() } // Register/relinquish the single D-pad-Up fallback slot BY IDENTITY. A // NavHost composes the ENTERING feed (which registers its own lambda) before // it disposes the EXITING one, so a blind null-on-dispose would drop the new @@ -368,7 +403,7 @@ fun TvMainShell( // lambda on both register and dispose; we only relinquish the slot for the // feed that still owns it, ignore a superseded feed's stale dispose, and let // a newly-entering feed take the slot (retiring the previous owner). - val onContentUpFallback: ((() -> Boolean)?) -> Unit = remember { + val onContentUpFallback: (((Boolean) -> Boolean)?) -> Unit = remember { { incoming -> if (incoming != null) { when { @@ -399,6 +434,7 @@ fun TvMainShell( // top), while ordinary content re-entry keeps the focusRestorer()'s // last-focused card. var contentFocusRequest by remember { mutableIntStateOf(0) } + var forYouEntryRequest by remember { mutableStateOf(TvForYouEntryRequest()) } // --- Skyline cascade panel host (Stage 4) ---------------------------------- // Mirrors tvOS `TVMainTabView.persistentPanels`. The cascade overlays are @@ -451,6 +487,7 @@ fun TvMainShell( val selectedRoot by remember(currentRoute) { derivedStateOf { mapRouteToRoot(currentRoute) } } + val selectedMenuFocusTarget = selectedRoot?.let(TvTopMenuPanel::Root) // Which libraries actually HAVE collections — gates the cascade's // Collections pill so an empty library doesn't offer a dead-end section @@ -522,9 +559,18 @@ fun TvMainShell( runCatching { contentFocusRequester.requestFocus() } } } + val openForYou: (SavedListSelection?) -> Unit = { selection -> + forYouEntryRequest = forYouEntryRequest.next(selection) + focusState.closePanel(false) + navigateToSecondary(TvMainRoute.ForYou.route) + moveFocusToContent(TvMainRoute.ForYou.route) + } val onSelectRoot: (TvRootDestination) -> Unit = { dest -> val route = dest.toRoute() + if (dest == TvRootDestination.ForYou) { + forYouEntryRequest = forYouEntryRequest.nextForTopLevelForYou() + } if (dest == TvRootDestination.Home) { // Detail return deliberately preserves the card that opened the // detail page, but that protection must end when the user @@ -695,7 +741,10 @@ fun TvMainShell( // half (close panel / dropdown); we run only the side effect // each action needs. Keeping it here — not in the selector or // the bar — means Back can never be double-handled. - when (focusState.onBack(onTabRoot = selectedRoot != null)) { + when (focusState.onBack( + onTabRoot = selectedRoot != null, + menuFocusTarget = selectedMenuFocusTarget, + )) { // Panel/dropdown already closed by onBack(): just consume. TvShellBackAction.ClosePanel, TvShellBackAction.CloseProfileMenu -> true @@ -777,18 +826,25 @@ fun TvMainShell( .onPreviewKeyEvent { ev -> when { ev.type == KeyEventType.KeyDown && ev.key == Key.DirectionUp -> { - val contentHandledUp = contentUpFallback?.invoke() + val isRepeat = ev.nativeKeyEvent.repeatCount > 0 + val contentHandledUp = contentUpFallback?.invoke(isRepeat) if (contentHandledUp != null) { - if (!contentHandledUp) { - focusState.requestMenuFocus() + if (shouldRequestMenuAfterContentUp(contentHandledUp, isRepeat)) { + focusState.requestMenuFocusIfAvailable( + selectedMenuFocusTarget, + allowNullTarget = currentRoute == TvMainRoute.Search.route, + ) } } else { // Try to move focus up inside content; if that // fails (we're already on the top row), hand // focus to the menu bar. val moved = focusManager.moveFocus(FocusDirection.Up) - if (!moved) { - focusState.requestMenuFocus() + if (shouldRequestMenuAfterContentUp(moved, isRepeat)) { + focusState.requestMenuFocusIfAvailable( + selectedMenuFocusTarget, + allowNullTarget = currentRoute == TvMainRoute.Search.route, + ) } } // Always consume: we performed the move (or routed @@ -826,8 +882,7 @@ fun TvMainShell( moveFocusToContent(TvMainRoute.Browse.route) }, onOpenForYou = { - navigateToSecondary(TvMainRoute.ForYou.route) - moveFocusToContent(TvMainRoute.ForYou.route) + openForYou(null) }, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, focusRequest = contentFocusRequest, @@ -848,8 +903,7 @@ fun TvMainShell( moveFocusToContent(TvMainRoute.Browse.route) }, onOpenForYou = { - navigateToSecondary(TvMainRoute.ForYou.route) - moveFocusToContent(TvMainRoute.ForYou.route) + openForYou(null) }, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, focusRequest = contentFocusRequest, @@ -958,9 +1012,10 @@ fun TvMainShell( } composable(TvMainRoute.ForYou.route) { TvRecommendationsScreen( - onItemClick = onOpenItemDetail, + onItemClick = openContentItemDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, focusRequest = contentFocusRequest, + entryRequest = forYouEntryRequest, ) } composable(TvMainRoute.Requests.route) { @@ -982,33 +1037,6 @@ fun TvMainShell( onInitialContentFocus = { focusState.closeProfileMenuForContent() }, ) } - composable(TvMainRoute.LiveTv.route) { - TvLiveTvScreen( - onChannelClick = { channel -> - navigateToSecondary( - TvMainRoute.LiveTvPlayer(channel.id, channel.displayName).route, - ) - }, - onInitialContentFocus = { focusState.closeProfileMenuForContent() }, - ) - } - composable( - route = TvMainRoute.LiveTvPlayer.ROUTE, - arguments = listOf( - navArgument(TvMainRoute.LiveTvPlayer.ARG_CHANNEL_ID) { type = NavType.StringType }, - navArgument(TvMainRoute.LiveTvPlayer.ARG_NAME) { - type = NavType.StringType - nullable = true - defaultValue = "" - }, - ), - ) { entry -> - TvLiveTvPlayerScreen( - channelId = entry.arguments?.getString(TvMainRoute.LiveTvPlayer.ARG_CHANNEL_ID).orEmpty(), - channelName = entry.arguments?.getString(TvMainRoute.LiveTvPlayer.ARG_NAME).orEmpty(), - onBack = { if (nestedNav.previousBackStackEntry != null) nestedNav.popBackStack() }, - ) - } composable( route = TvMainRoute.RequestDetail.ROUTE, arguments = listOf( @@ -1077,7 +1105,13 @@ fun TvMainShell( focusState.closeProfileMenuForContent() calendarFocusHandoffPending = false }, + onMoveUpToMenu = { + focusState.requestMenuFocus( + TvTopMenuPanel.Root(TvRootDestination.Calendar), + ) + }, focusRequest = contentFocusRequest, + onContentUpFallbackChanged = onContentUpFallback, ) } composable(TvMainRoute.Browse.route) { @@ -1137,6 +1171,31 @@ fun TvMainShell( } } + // The scrim TvTopMenuBar documents but the shell had stopped drawing. + // The bar deliberately has no background band of its own ("the SHELL + // draws a fixed top scrim behind the bar", QA 2026-07-08); without it + // the labels sat directly on whatever scrolled underneath, which on + // For You is a poster row and is unreadable. A gradient rather than a + // solid band keeps the tvOS look this shell asks for — content stays + // visible behind the bar, just no longer competing with the labels. + if (currentRoute != TvMainRoute.Settings.route) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(TvTopMenuLayout.contentTopInset) + .align(Alignment.TopCenter) + .background( + Brush.verticalGradient( + listOf( + MaterialTheme.colorScheme.background.copy(alpha = 0.92f), + MaterialTheme.colorScheme.background.copy(alpha = 0.72f), + MaterialTheme.colorScheme.background.copy(alpha = 0f), + ), + ), + ), + ) + } + // Menu overlay — content remains visible behind the transparent bar, // matching tvOS without a heavy top-edge shadow. TvTopMenuBar( @@ -1240,19 +1299,13 @@ fun TvMainShell( entersPanel = active && focusState.panelEntersFocus, focusEntryToken = focusState.panelFocusEntryToken, onWatchlist = { - focusState.closePanel(false) - navigateToSecondary(TvMainRoute.Watchlist.route) - moveFocusToContent(TvMainRoute.Watchlist.route) + openForYou(SavedListSelection.Watchlist) }, onFavorites = { - focusState.closePanel(false) - navigateToSecondary(TvMainRoute.Favorites.route) - moveFocusToContent(TvMainRoute.Favorites.route) + openForYou(SavedListSelection.Favorites) }, onRecommendations = { - focusState.closePanel(false) - navigateToSecondary(TvMainRoute.ForYou.route) - moveFocusToContent(TvMainRoute.ForYou.route) + openForYou(null) }, ) } @@ -1325,10 +1378,11 @@ fun TvMainShell( navigateToSecondary(TvMainRoute.Requests.route) moveFocusToContent(TvMainRoute.Requests.route) }, - showLiveTv = liveTvEnabled, - onLiveTv = closeMenuAnd { - navigateToSecondary(TvMainRoute.LiveTv.route) - moveFocusToContent(TvMainRoute.LiveTv.route) + showWatchTogether = CLIENT_WATCH_TOGETHER_SURFACE_ENABLED, + onWatchTogether = { + focusState.closeProfileMenuForContent() + watchTogetherViewModel.clearError() + watchTogetherEntryOpen = true }, onSettings = { // Keep focus on the dropdown row through the route fade. @@ -1351,6 +1405,38 @@ fun TvMainShell( .zIndex(2f), ) } + + if (watchTogetherEntryOpen) { + if (watchTogetherJoinOpen) { + TvJoinCodeDialog( + isBusy = watchTogetherState.isBusy, + error = watchTogetherState.error, + onJoin = watchTogetherViewModel::joinRoom, + onDismiss = { + watchTogetherViewModel.clearError() + watchTogetherJoinOpen = false + }, + ) + } else { + TvWatchTogetherMenuEntryDialog( + canResume = currentWatchTogetherRoom != null, + isBusy = watchTogetherState.isBusy, + error = watchTogetherState.error, + onResume = { watchTogetherViewModel.resumeCurrentRoom() }, + onHost = { watchTogetherViewModel.createEmptyVoteRoom() }, + onJoin = { + watchTogetherViewModel.clearError() + watchTogetherJoinOpen = true + }, + onDismiss = { + watchTogetherViewModel.clearError() + watchTogetherEntryOpen = false + watchTogetherJoinOpen = false + focusState.dismissProfileMenu() + }, + ) + } + } } } @@ -1373,7 +1459,7 @@ private fun TvLibraryTypeContent( onLibraryCollectionClick: (libraryId: Int, collectionId: String, title: String) -> Unit, onUserCollectionClick: (collectionId: String, title: String) -> Unit, onInitialContentFocus: () -> Unit, - onContentUpFallbackChanged: (((() -> Boolean)?) -> Unit)? = null, + onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, ) { if (library == null) { // Only assert "no libraries" once loading has settled AND this type @@ -1522,8 +1608,8 @@ private fun cascadePanelOffset( * returns focus to the avatar via [onDismiss]. * * Row set + order mirrors tvOS: Switch Profile · Watchlist · Favorites · - * History · Requests (feature-gated) · Settings · Switch Server · Sign Out. - * Calendar is a top-level tab. + * History · Requests (server-gated) · Watch Together (client-policy-gated) · + * Settings · Switch Server · Sign Out. Calendar is a top-level tab. */ @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -1537,8 +1623,8 @@ private fun TvProfileDropdown( onHistory: () -> Unit, showRequests: Boolean, onRequests: () -> Unit, - showLiveTv: Boolean, - onLiveTv: () -> Unit, + showWatchTogether: Boolean, + onWatchTogether: () -> Unit, onSettings: () -> Unit, onSwitchServer: () -> Unit, onSignOut: () -> Unit, @@ -1591,11 +1677,11 @@ private fun TvProfileDropdown( onClick = onRequests, ) } - if (showLiveTv) { + if (showWatchTogether) { ProfileDropdownRow( - label = "Live TV", - icon = Icons.Filled.LiveTv, - onClick = onLiveTv, + label = "Watch Together", + icon = Icons.Filled.People, + onClick = onWatchTogether, ) } @@ -1646,7 +1732,7 @@ private fun ProfileDropdownHeader(accountState: TvAccountState) { } else { Text( text = avatarText, - color = PrairieOnSurface, + color = SiloOnSurface, fontWeight = FontWeight.Bold, ) } @@ -1654,7 +1740,7 @@ private fun ProfileDropdownHeader(accountState: TvAccountState) { Column(modifier = Modifier.weight(1f)) { Text( text = accountState.displayName, - color = PrairieOnSurface, + color = SiloOnSurface, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleMedium.copy( fontSize = TvSkyline.profileMenuHeaderTitleSize, @@ -1669,7 +1755,7 @@ private fun ProfileDropdownHeader(accountState: TvAccountState) { if (subtitle.isNotEmpty()) { Text( text = subtitle.uppercase(), - color = PrairieOnSurface.copy(alpha = 0.38f), + color = SiloOnSurface.copy(alpha = 0.38f), style = MaterialTheme.typography.labelSmall.copy( fontSize = TvSkyline.profileMenuHeaderSubtitleSize, lineHeight = TvSkyline.profileMenuHeaderSubtitleLineHeight, @@ -1710,14 +1796,14 @@ private fun TvServerOfflinePill( colors = ClickableSurfaceDefaults.colors( containerColor = Color(0xFF3A1F22).copy(alpha = 0.94f), contentColor = Color.White, - focusedContainerColor = PrairieOnSurface, + focusedContainerColor = SiloOnSurface, focusedContentColor = DarkBackground, - pressedContainerColor = PrairieOnSurface, + pressedContainerColor = SiloOnSurface, pressedContentColor = DarkBackground, ), border = ClickableSurfaceDefaults.border( border = Border(border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)), shape = pillShape), - focusedBorder = Border(border = BorderStroke(1.dp, PrairieOnSurface), shape = pillShape), + focusedBorder = Border(border = BorderStroke(1.dp, SiloOnSurface), shape = pillShape), ), ) { Text( @@ -1733,7 +1819,7 @@ private fun TvServerOfflinePill( /** * Inverted-capsule dropdown row (tvOS §5.8 / cascade grammar): a leading icon - * and label that invert to a solid [PrairieOnSurface] fill with + * and label that invert to a solid [SiloOnSurface] fill with * [DarkBackground] content on focus, bare at rest. */ @Composable @@ -1745,7 +1831,7 @@ private fun ProfileDropdownRow( ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() - val contentColor = if (isFocused) DarkBackground else PrairieOnSurface.copy(alpha = 0.9f) + val contentColor = if (isFocused) DarkBackground else SiloOnSurface.copy(alpha = 0.9f) val rowShape = RoundedCornerShape(TvSkyline.profileMenuRowCornerRadius) Surface( @@ -1759,10 +1845,10 @@ private fun ProfileDropdownRow( scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), colors = ClickableSurfaceDefaults.colors( containerColor = Color.Transparent, - contentColor = PrairieOnSurface, - focusedContainerColor = PrairieOnSurface, + contentColor = SiloOnSurface, + focusedContainerColor = SiloOnSurface, focusedContentColor = DarkBackground, - pressedContainerColor = PrairieOnSurface, + pressedContainerColor = SiloOnSurface, pressedContentColor = DarkBackground, ), border = ClickableSurfaceDefaults.border( diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusState.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusState.kt index ab51330e0..10c24a46b 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusState.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusState.kt @@ -182,6 +182,16 @@ class TvShellFocusState { menuFocusRequest++ } + /** + * Route content focus back to the bar only when the shell has a concrete root + * target, or when a route-specific owner intentionally handles a null target + * (currently Search). Other secondary routes must not fall through to Home. + */ + fun requestMenuFocusIfAvailable(target: TvTopMenuPanel?, allowNullTarget: Boolean = false) { + if (target == null && !allowNullTarget) return + requestMenuFocus(target) + } + /** * Record whether a bar button holds focus. Focus on the bar means we are not * inside a panel, so clear any stale entered flag — otherwise a geometric @@ -289,7 +299,10 @@ class TvShellFocusState { * and [TvShellBackAction.DelegateToNav] are left to the composable, which owns * the focus manager and nav controller). */ - fun onBack(onTabRoot: Boolean): TvShellBackAction { + fun onBack( + onTabRoot: Boolean, + menuFocusTarget: TvTopMenuPanel? = null, + ): TvShellBackAction { val action = tvShellBackAction( panelOpen = openPanel != null, profileMenuOpen = profileMenuOpen, @@ -299,7 +312,7 @@ class TvShellFocusState { when (action) { TvShellBackAction.ClosePanel -> closePanel(returnFocusToBar = true) TvShellBackAction.CloseProfileMenu -> dismissProfileMenu() - TvShellBackAction.MoveFocusToMenu -> requestMenuFocus() + TvShellBackAction.MoveFocusToMenu -> requestMenuFocus(menuFocusTarget) TvShellBackAction.MenuBack, TvShellBackAction.DelegateToNav -> Unit } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuBar.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuBar.kt index fbae79ac3..ff74bfc90 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuBar.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuBar.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -229,15 +230,39 @@ fun TvTopMenuBar( // suppression lift (e.g. closing the profile dropdown, which flips // isFocusSuppressed false) does NOT also re-grab the selected tab and fight // the dedicated profile-avatar focus path below. - var lastHandledFocusRequest by remember { mutableStateOf(0) } - LaunchedEffect(focusRequest, isFocusSuppressed) { - if (isFocusSuppressed) return@LaunchedEffect - if (focusRequest == lastHandledFocusRequest) return@LaunchedEffect - lastHandledFocusRequest = focusRequest - val explicitFocus = focusRequestTarget?.let(::focusForPanel) - dwellSuppressedButton = explicitFocus - val requester = explicitFocus?.let(::requesterForFocus) ?: selectedEntryRequester() - runCatching { requester.requestFocus() } + val currentFocusRequestTarget by rememberUpdatedState(focusRequestTarget) + val currentDestinations by rememberUpdatedState(destinations) + var lastHandledFocusRequest by remember { mutableStateOf>(0 to null) } + val focusRequestIdentity = focusRequest to focusRequestTarget + val focusRequestTargetAvailable = + isTopMenuFocusTargetAvailable(focusRequestTarget, destinations) + LaunchedEffect( + focusRequest, + focusRequestTarget, + isFocusSuppressed, + focusRequestTargetAvailable, + ) { + lastHandledFocusRequest = handleTopMenuFocusRequestIfAvailable( + requestIdentity = focusRequestIdentity, + lastHandledRequest = lastHandledFocusRequest, + isFocusSuppressed = isFocusSuppressed, + isTargetAvailable = focusRequestTargetAvailable, + requestFocus = { + val explicitFocus = focusRequestTarget?.let(::focusForPanel) + dwellSuppressedButton = explicitFocus + val requester = explicitFocus?.let(::requesterForFocus) ?: selectedEntryRequester() + requestTopMenuFocusUntilApplied( + awaitFrame = { androidx.compose.runtime.withFrameNanos { } }, + isTargetCurrent = { + currentFocusRequestTarget == focusRequestTarget && + isTopMenuFocusTargetAvailable(focusRequestTarget, currentDestinations) + }, + requestFocus = { + runCatching { requester.requestFocus() }.getOrDefault(false) + }, + ) + }, + ) } LaunchedEffect(focusedButton) { diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequest.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequest.kt new file mode 100644 index 000000000..1c8089c1a --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequest.kt @@ -0,0 +1,38 @@ +package org.prairieserver.prairie.tv.ui.shell + +private const val TopMenuFocusMaxAttempts = 6 + +internal fun isTopMenuFocusTargetAvailable( + target: TvTopMenuPanel?, + destinations: List, +): Boolean = when (target) { + is TvTopMenuPanel.Root -> target.dest in destinations + TvTopMenuPanel.Profile, null -> true +} + +internal suspend fun handleTopMenuFocusRequestIfAvailable( + requestIdentity: Pair, + lastHandledRequest: Pair, + isFocusSuppressed: Boolean, + isTargetAvailable: Boolean, + requestFocus: suspend () -> Boolean, +): Pair { + if (isFocusSuppressed || !isTargetAvailable || requestIdentity == lastHandledRequest) { + return lastHandledRequest + } + return if (requestFocus()) requestIdentity else lastHandledRequest +} + +internal suspend fun requestTopMenuFocusUntilApplied( + awaitFrame: suspend () -> Unit, + isTargetCurrent: () -> Boolean = { true }, + requestFocus: () -> Boolean, +): Boolean { + repeat(TopMenuFocusMaxAttempts) { + if (!isTargetCurrent()) return false + awaitFrame() + if (!isTargetCurrent()) return false + if (requestFocus()) return true + } + return false +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/data/preferences/LegacyTvPrefsMigrationTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/data/preferences/LegacyTvPrefsMigrationTest.kt index da3e80406..b41931aea 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/data/preferences/LegacyTvPrefsMigrationTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/data/preferences/LegacyTvPrefsMigrationTest.kt @@ -11,9 +11,11 @@ import org.prairieserver.prairie.common.settings.AndroidServerSettingsCache import org.prairieserver.prairie.common.settings.PlayerSettingsStore import org.prairieserver.prairie.model.settings.EffectiveSetting import org.prairieserver.prairie.model.settings.PlaybackSettingsKeys +import org.prairieserver.prairie.model.settings.QualityPresets import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.tv.testing.FakePlayerSettingsStore import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -25,6 +27,8 @@ import java.io.File 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 LegacyTvPrefsMigrationTest { @@ -102,6 +106,10 @@ class LegacyTvPrefsMigrationTest { migration.migrateIfNeeded() assertEquals("1080p", fakePlayerStore.preferredQualityFlow.value) + // Both axes, or the pair matches no preset and the picker renders + // nothing as selected — see `imported quality is a pair the picker can + // select`. + assertEquals(6000, fakePlayerStore.maxBitrateKbpsFlow.value) assertEquals(false, fakePlayerStore.autoPlayNextFlow.value) assertEquals(true, fakePlayerStore.autoSkipIntroFlow.value) assertEquals(true, fakePlayerStore.autoSkipCreditsFlow.value) @@ -136,11 +144,122 @@ class LegacyTvPrefsMigrationTest { newMigration(legacy, effective).migrateIfNeeded() assertFalse(fakePlayerStore.setterCalls.contains("setPreferredQuality")) + assertFalse(fakePlayerStore.setterCalls.contains("setQuality")) assertEquals("auto", fakePlayerStore.preferredQualityFlow.value) // Keys without a server override still import. assertEquals(true, fakePlayerStore.autoSkipIntroFlow.value) } + @Test + fun `an existing bitrate override alone still blocks the quality import`() = runTest { + // Quality is two rows and setQuality writes both, so guarding on the + // resolution alone lets a device that has only a server-side bitrate + // cap pass — and the legacy preset's bitrate (or JSON null, for a + // legacy Auto) overwrites the cap the migration promised to preserve. + val legacy = legacyStore() + legacy.edit { prefs -> + prefs[legacyQualityKey] = "720p" + prefs[legacyAutoSkipIntroKey] = true + } + val effective = mapOf( + PlaybackSettingsKeys.MaxBitrateKbps to EffectiveSetting( + key = PlaybackSettingsKeys.MaxBitrateKbps, + effectiveValue = "3000", + source = "device", + hasDeviceOverride = true, + ), + ) + newMigration(legacy, effective).migrateIfNeeded() + + assertFalse( + fakePlayerStore.setterCalls.contains("setQuality"), + "a server-side bitrate override must not be overwritten by the legacy preset", + ) + // Keys without an override still import. + assertEquals(true, fakePlayerStore.autoSkipIntroFlow.value) + } + + @Test + fun `the quality guard asks the server about both axes`() = runTest { + // The guard can only preserve what it queries: a bitrate key missing + // from the request comes back absent, which reads as "no override". + val legacy = legacyStore() + legacy.edit { prefs -> prefs[legacyQualityKey] = "720p" } + val requested = mutableListOf() + LegacyTvPrefsMigration( + context = mockContextStub(), + settingsCache = fakeCache, + playerSettingsStore = fakePlayerStore, + librarySelectionStore = selectionStore, + getServerUrl = { tokenManager.getServerUrl() }, + getProfileId = { tokenManager.getProfileId() }, + getEffectiveSettings = { keys -> requested.addAll(keys); emptyMap() }, + legacyStoreProvider = { legacy }, + ).migrateIfNeeded() + + assertTrue(PlaybackSettingsKeys.PreferredQuality in requested) + assertTrue( + PlaybackSettingsKeys.MaxBitrateKbps in requested, + "an unqueried axis cannot be guarded", + ) + } + + /** + * Every legacy quality value must land on a pair the picker can show as + * selected. Quality is two axes now; a resolution imported without its + * bitrate is a combination `QualityPresets.presetFor` does not match, so + * `TvSettingsScreen`'s picker computes an empty selected id, renders no + * checkmark, and parks the cursor on Auto — and because the sentinel is + * marked on the same pass, the import cannot be repeated to repair it. + */ + @Test + fun `imported quality is a pair the picker can select`() = runTest { + for (legacy in PlaybackQuality.entries) { + fakePlayerStore = FakePlayerSettingsStore() + fakeCache = FakeSettingsCache() + val store = PreferenceDataStoreFactory.create( + produceFile = { File(tempFolder.root, "tv_prefs_${legacy.name}.preferences_pb") }, + ) + store.edit { prefs -> prefs[legacyQualityKey] = legacy.wireValue } + + newMigration(store).migrateIfNeeded() + + val resolution = fakePlayerStore.preferredQualityFlow.value + val bitrate = fakePlayerStore.maxBitrateKbpsFlow.value + assertNotNull( + QualityPresets.presetFor(resolution, bitrate), + "legacy ${legacy.wireValue} imported as ($resolution, $bitrate), " + + "which no picker preset covers", + ) + assertEquals(legacy.wireValue, resolution) + } + } + + @Test + fun `a legacy quality with an implied cap imports that cap`() = runTest { + // The bitrates match the server's own migration + // (internal/settingsmigrate/plan.go decomposes 720p to {720p, 2000}), + // so the same legacy value means the same thing on both sides. + val legacy = legacyStore() + legacy.edit { prefs -> prefs[legacyQualityKey] = "720p" } + + newMigration(legacy).migrateIfNeeded() + + assertEquals("720p", fakePlayerStore.preferredQualityFlow.value) + assertEquals(2000, fakePlayerStore.maxBitrateKbpsFlow.value) + } + + @Test + fun `a legacy quality with no implied cap imports uncapped`() = runTest { + val legacy = legacyStore() + legacy.edit { prefs -> prefs[legacyQualityKey] = "2160p" } + + newMigration(legacy).migrateIfNeeded() + + assertEquals("2160p", fakePlayerStore.preferredQualityFlow.value) + assertNull(fakePlayerStore.maxBitrateKbpsFlow.value) + } + @Test fun `pre-existing playback sentinel skips playback import but library still migrates`() = runTest { fakeCache.markMigrationComplete(serverUrl, playbackScope) @@ -226,129 +345,6 @@ class LegacyTvPrefsMigrationTest { } /** Records setter calls and mirrors them into MutableStateFlows. */ -private class FakePlayerSettingsStore : PlayerSettingsStore { - val setterCalls = mutableListOf() - var flushCount = 0 - - override val autoSkipIntroFlow = MutableStateFlow(false) - override val autoSkipCreditsFlow = MutableStateFlow(false) - override val autoPlayNextFlow = MutableStateFlow(true) - override val hdrEnabledFlow = MutableStateFlow(true) - override val dvProfile7HDR10FallbackFlow = MutableStateFlow(false) - override val dolbyVisionEnabledFlow = MutableStateFlow(true) - override val matchContentFrameRateFlow = MutableStateFlow(false) - override val subtitleMatchesDeviceFlow = MutableStateFlow(false) - override val showAudiobooksFlow = MutableStateFlow(false) - override val effectiveSubtitleAppearanceFlow = - MutableStateFlow(org.prairieserver.prairie.model.settings.SubtitleAppearance.DEFAULT) - override val pictureInPictureEnabledFlow = MutableStateFlow(true) - override val downloadsWifiOnlyFlow = MutableStateFlow(true) - override val keepWatchedDownloadsFlow = MutableStateFlow(false) - override val defaultDownloadQualityFlow = MutableStateFlow("original") - override val playbackSpeedFlow = MutableStateFlow(1.0) - override val audioSyncMsFlow = MutableStateFlow(0) - override val subtitleSyncMsFlow = MutableStateFlow(0) - override fun subtitleSyncMsFor(contentId: String?) = subtitleSyncMsFlow - override suspend fun setSubtitleSyncMsFor(contentId: String, value: Int) = Unit - override val nextUpPromptSecondsFlow = MutableStateFlow(30) - override val sleepTimerDefaultMinutesFlow = MutableStateFlow(0) - override val resumeRewindSecondsFlow = MutableStateFlow(7) - override val passOutThresholdFlow = MutableStateFlow(3) - override val preferredQualityFlow = MutableStateFlow("auto") - override val audioLanguageFlow = MutableStateFlow("") - override val videoGravityFlow = MutableStateFlow("fit") - override val orientationModeFlow = MutableStateFlow("auto") - override val subtitleAppearanceFlow = MutableStateFlow(SubtitleAppearance.DEFAULT) - override val subtitleUsesDeviceOverrideFlow = MutableStateFlow(false) - - override suspend fun setAutoSkipIntro(value: Boolean) { - setterCalls += "setAutoSkipIntro"; autoSkipIntroFlow.value = value - } - override suspend fun setAutoSkipCredits(value: Boolean) { - setterCalls += "setAutoSkipCredits"; autoSkipCreditsFlow.value = value - } - override suspend fun setAutoPlayNext(value: Boolean) { - setterCalls += "setAutoPlayNext"; autoPlayNextFlow.value = value - } - override suspend fun setHdrEnabled(value: Boolean) { - setterCalls += "setHdrEnabled"; hdrEnabledFlow.value = value - } - override suspend fun setDvProfile7HDR10Fallback(value: Boolean) { - setterCalls += "setDvProfile7HDR10Fallback"; dvProfile7HDR10FallbackFlow.value = value - } - - override suspend fun setDolbyVisionEnabled(value: Boolean) { - setterCalls += "setDolbyVisionEnabled"; dolbyVisionEnabledFlow.value = value - } - - override suspend fun setMatchContentFrameRate(value: Boolean) { - setterCalls += "setMatchContentFrameRate"; matchContentFrameRateFlow.value = value - } - - override suspend fun setSubtitleMatchesDevice(enabled: Boolean) { - setterCalls += "setSubtitleMatchesDevice"; subtitleMatchesDeviceFlow.value = enabled - } - - override suspend fun setShowAudiobooks(enabled: Boolean) { - setterCalls += "setShowAudiobooks"; showAudiobooksFlow.value = enabled - } - override suspend fun setPictureInPictureEnabled(value: Boolean) { - setterCalls += "setPictureInPictureEnabled"; pictureInPictureEnabledFlow.value = value - } - override suspend fun setDownloadsWifiOnly(value: Boolean) { - setterCalls += "setDownloadsWifiOnly"; downloadsWifiOnlyFlow.value = value - } - override suspend fun setKeepWatchedDownloads(value: Boolean) { - setterCalls += "setKeepWatchedDownloads"; keepWatchedDownloadsFlow.value = value - } - override suspend fun setDefaultDownloadQuality(value: String) { - setterCalls += "setDefaultDownloadQuality"; defaultDownloadQualityFlow.value = value - } - override suspend fun setPlaybackSpeed(value: Double) { - setterCalls += "setPlaybackSpeed"; playbackSpeedFlow.value = value - } - override suspend fun setAudioSyncMs(value: Int) { - setterCalls += "setAudioSyncMs"; audioSyncMsFlow.value = value - } - override suspend fun setSubtitleSyncMs(value: Int) { - setterCalls += "setSubtitleSyncMs"; subtitleSyncMsFlow.value = value - } - override suspend fun setNextUpPromptSeconds(value: Int) { - setterCalls += "setNextUpPromptSeconds"; nextUpPromptSecondsFlow.value = value - } - override suspend fun setSleepTimerDefaultMinutes(value: Int) { - setterCalls += "setSleepTimerDefaultMinutes"; sleepTimerDefaultMinutesFlow.value = value - } - override suspend fun setResumeRewindSeconds(value: Int) { - setterCalls += "setResumeRewindSeconds"; resumeRewindSecondsFlow.value = value - } - override suspend fun setPassOutThreshold(value: Int) { - setterCalls += "setPassOutThreshold"; passOutThresholdFlow.value = value - } - override suspend fun setPreferredQuality(value: String) { - setterCalls += "setPreferredQuality"; preferredQualityFlow.value = value - } - override suspend fun setAudioLanguage(value: String) { - setterCalls += "setAudioLanguage"; audioLanguageFlow.value = value - } - override suspend fun setVideoGravity(value: String) { - setterCalls += "setVideoGravity"; videoGravityFlow.value = value - } - override suspend fun setOrientationMode(value: String) { - setterCalls += "setOrientationMode"; orientationModeFlow.value = value - } - override suspend fun setSubtitleAppearance(value: SubtitleAppearance) { - setterCalls += "setSubtitleAppearance"; subtitleAppearanceFlow.value = value - } - - override suspend fun refreshFromServer() {} - override suspend fun setSubtitleDeviceOverrideEnabled(enabled: Boolean) {} - override suspend fun resetDeviceSetting(key: String) {} - override suspend fun resetAllDeviceSettings() {} - override suspend fun flushPendingDeviceSettings() { - flushCount++ - } -} /** * In-memory sentinel store — bypasses the SharedPreferences-backed base diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/testing/FakePlayerSettingsStore.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/testing/FakePlayerSettingsStore.kt new file mode 100644 index 000000000..c64b90f8b --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/testing/FakePlayerSettingsStore.kt @@ -0,0 +1,144 @@ +package org.prairieserver.prairie.tv.testing + +import org.prairieserver.prairie.common.settings.PlayerSettingsStore +import org.prairieserver.prairie.model.settings.SubtitleAppearance +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * In-memory [PlayerSettingsStore] for TV unit tests: every flow is a + * [MutableStateFlow] a test can read back, and every setter records its own + * name in [setterCalls] so a test can assert which write path ran. + * + * Shared rather than re-declared per test class — the interface is wide enough + * that a second copy drifts the moment a member is added. + */ +internal class FakePlayerSettingsStore : PlayerSettingsStore { + val setterCalls = mutableListOf() + var flushCount = 0 + + override val autoSkipIntroFlow = MutableStateFlow(false) + override val autoSkipCreditsFlow = MutableStateFlow(false) + override val autoPlayNextFlow = MutableStateFlow(true) + override val hdrEnabledFlow = MutableStateFlow(true) + override val dvProfile7HDR10FallbackFlow = MutableStateFlow(false) + override val dolbyVisionEnabledFlow = MutableStateFlow(true) + override val matchContentFrameRateFlow = MutableStateFlow(false) + override val subtitleMatchesDeviceFlow = MutableStateFlow(false) + override val showAudiobooksFlow = MutableStateFlow(false) + override val effectiveSubtitleAppearanceFlow = + MutableStateFlow(org.prairieserver.prairie.model.settings.SubtitleAppearance.DEFAULT) + override val pictureInPictureEnabledFlow = MutableStateFlow(true) + override val downloadsWifiOnlyFlow = MutableStateFlow(true) + override val keepWatchedDownloadsFlow = MutableStateFlow(false) + override val defaultDownloadQualityFlow = MutableStateFlow("original") + override val playbackSpeedFlow = MutableStateFlow(1.0) + override val audioSyncMsFlow = MutableStateFlow(0) + override val subtitleSyncMsFlow = MutableStateFlow(0) + override val nextUpPromptSecondsFlow = MutableStateFlow(30) + override val sleepTimerDefaultMinutesFlow = MutableStateFlow(0) + override val resumeRewindSecondsFlow = MutableStateFlow(7) + override val passOutThresholdFlow = MutableStateFlow(3) + override val preferredQualityFlow = MutableStateFlow("auto") + override val maxBitrateKbpsFlow = MutableStateFlow(null) + override val audioLanguageFlow = MutableStateFlow("") + override val videoGravityFlow = MutableStateFlow("fit") + override val orientationModeFlow = MutableStateFlow("auto") + override val subtitleAppearanceFlow = MutableStateFlow(SubtitleAppearance.DEFAULT) + override val subtitleUsesDeviceOverrideFlow = MutableStateFlow(false) + + override suspend fun setAutoSkipIntro(value: Boolean) { + setterCalls += "setAutoSkipIntro"; autoSkipIntroFlow.value = value + } + override suspend fun setAutoSkipCredits(value: Boolean) { + setterCalls += "setAutoSkipCredits"; autoSkipCreditsFlow.value = value + } + override suspend fun setAutoPlayNext(value: Boolean) { + setterCalls += "setAutoPlayNext"; autoPlayNextFlow.value = value + } + override suspend fun setHdrEnabled(value: Boolean) { + setterCalls += "setHdrEnabled"; hdrEnabledFlow.value = value + } + override suspend fun setDvProfile7HDR10Fallback(value: Boolean) { + setterCalls += "setDvProfile7HDR10Fallback"; dvProfile7HDR10FallbackFlow.value = value + } + + override suspend fun setDolbyVisionEnabled(value: Boolean) { + setterCalls += "setDolbyVisionEnabled"; dolbyVisionEnabledFlow.value = value + } + + override suspend fun setMatchContentFrameRate(value: Boolean) { + setterCalls += "setMatchContentFrameRate"; matchContentFrameRateFlow.value = value + } + + override suspend fun setSubtitleMatchesDevice(enabled: Boolean) { + setterCalls += "setSubtitleMatchesDevice"; subtitleMatchesDeviceFlow.value = enabled + } + + override suspend fun setShowAudiobooks(enabled: Boolean) { + setterCalls += "setShowAudiobooks"; showAudiobooksFlow.value = enabled + } + override suspend fun setPictureInPictureEnabled(value: Boolean) { + setterCalls += "setPictureInPictureEnabled"; pictureInPictureEnabledFlow.value = value + } + override suspend fun setDownloadsWifiOnly(value: Boolean) { + setterCalls += "setDownloadsWifiOnly"; downloadsWifiOnlyFlow.value = value + } + override suspend fun setKeepWatchedDownloads(value: Boolean) { + setterCalls += "setKeepWatchedDownloads"; keepWatchedDownloadsFlow.value = value + } + override suspend fun setDefaultDownloadQuality(value: String) { + setterCalls += "setDefaultDownloadQuality"; defaultDownloadQualityFlow.value = value + } + override suspend fun setPlaybackSpeed(value: Double) { + setterCalls += "setPlaybackSpeed"; playbackSpeedFlow.value = value + } + override suspend fun setAudioSyncMs(value: Int) { + setterCalls += "setAudioSyncMs"; audioSyncMsFlow.value = value + } + override suspend fun setSubtitleSyncMs(value: Int) { + setterCalls += "setSubtitleSyncMs"; subtitleSyncMsFlow.value = value + } + override suspend fun setNextUpPromptSeconds(value: Int) { + setterCalls += "setNextUpPromptSeconds"; nextUpPromptSecondsFlow.value = value + } + override suspend fun setSleepTimerDefaultMinutes(value: Int) { + setterCalls += "setSleepTimerDefaultMinutes"; sleepTimerDefaultMinutesFlow.value = value + } + override suspend fun setResumeRewindSeconds(value: Int) { + setterCalls += "setResumeRewindSeconds"; resumeRewindSecondsFlow.value = value + } + override suspend fun setPassOutThreshold(value: Int) { + setterCalls += "setPassOutThreshold"; passOutThresholdFlow.value = value + } + override suspend fun setPreferredQuality(value: String) { + setterCalls += "setPreferredQuality"; preferredQualityFlow.value = value + } + override suspend fun setQuality(resolution: String, bitrateKbps: Int?) { + setterCalls += "setQuality" + preferredQualityFlow.value = resolution + maxBitrateKbpsFlow.value = bitrateKbps + } + override suspend fun setAudioLanguage(value: String) { + setterCalls += "setAudioLanguage"; audioLanguageFlow.value = value + } + override suspend fun setVideoGravity(value: String) { + setterCalls += "setVideoGravity"; videoGravityFlow.value = value + } + override suspend fun setOrientationMode(value: String) { + setterCalls += "setOrientationMode"; orientationModeFlow.value = value + } + override suspend fun setSubtitleAppearance(value: SubtitleAppearance) { + setterCalls += "setSubtitleAppearance"; subtitleAppearanceFlow.value = value + } + override suspend fun flushProjectedSubtitleAppearance() { + setterCalls += "flushProjectedSubtitleAppearance" + } + + override suspend fun refreshFromServer() {} + override suspend fun setSubtitleDeviceOverrideEnabled(enabled: Boolean) {} + override suspend fun resetDeviceSetting(key: String) {} + override suspend fun resetAllDeviceSettings() {} + override suspend fun flushPendingDeviceSettings() { + flushCount++ + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeEnrichmentTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeEnrichmentTest.kt index 51d84eec0..73137b512 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeEnrichmentTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeEnrichmentTest.kt @@ -4,7 +4,9 @@ import org.prairieserver.prairie.model.catalog.CastMember import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.model.section.SectionItem import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test /** @@ -136,34 +138,98 @@ class TvFocusMarqueeEnrichmentTest { } @Test - fun `initial seed joins the coordinated candidate and does not replace it`() { + fun `page entry reseeds a stale candidate when its row identity changes`() { + val state = TvFocusMarqueeState() + val item = SectionItem(contentId = "item-1", type = "movie", title = "Item") + + state.seedInitialPreview(item, "Row", rowIdentity = "row-old") + state.commit(state.candidate) + state.seedInitialPreview(item, "Row", rowIdentity = "row-new") + + assertEquals("row-new#item-1", state.candidate?.id) + } + + @Test + fun `page entry seed never replaces settled real focus`() { + val state = TvFocusMarqueeState() + val focusedItem = SectionItem( + contentId = "focused-item", + type = "movie", + title = "Focused", + ) + val seedItem = SectionItem( + contentId = "seed-item", + type = "movie", + title = "Replacement", + ) + + state.preview(focusedItem, "Focused", rowIdentity = "focused-row") + state.commit(state.candidate) + state.seedInitialPreview(seedItem, "Replacement", rowIdentity = "replacement-row") + + assertEquals("focused-row#focused-item", state.content?.id) + assertEquals("focused-row#focused-item", state.candidate?.id) + } + + @Test + fun `page entry seed never replaces pending real focus`() { + val state = TvFocusMarqueeState() + val initialItem = SectionItem( + contentId = "initial-item", + type = "movie", + title = "Initial", + ) + val focusedItem = SectionItem( + contentId = "focused-item", + type = "movie", + title = "Focused", + ) + val seedItem = SectionItem( + contentId = "seed-item", + type = "movie", + title = "Replacement", + ) + + state.seedInitialPreview(initialItem, "Initial", rowIdentity = "initial-row") + state.commit(state.candidate) + state.preview(focusedItem, "Focused", rowIdentity = "focused-row") + state.seedInitialPreview(seedItem, "Replacement", rowIdentity = "replacement-row") + + assertEquals("initial-row#initial-item", state.content?.id) + assertEquals("focused-row#focused-item", state.candidate?.id) + } + + @Test + fun `initial seed does not enable network enrichment until real focus`() { val state = TvFocusMarqueeState() val first = SectionItem( contentId = "first", type = "movie", title = "First Movie", - backdropUrl = "https://art/first.jpg", - ) - val second = SectionItem( - contentId = "second", - type = "movie", - title = "Second Movie", - backdropUrl = "https://art/second.jpg", ) state.seedInitialPreview(first, "Continue Watching") + state.commit(state.candidate) + assertFalse(state.hasSettledRealFocus) - assertNull(state.content) - assertEquals("First Movie", state.candidate?.title) - - state.seedInitialPreview(second, "Next Row") + state.preview(first, "Continue Watching") + assertTrue(state.hasSettledRealFocus) + } - assertEquals("First Movie", state.candidate?.title) + @Test + fun `new raw focus does not enrich the old seed before settlement`() { + val state = TvFocusMarqueeState() + val first = SectionItem(contentId = "first", type = "movie", title = "First") + val second = SectionItem(contentId = "second", type = "movie", title = "Second") + state.seedInitialPreview(first, "First row", rowIdentity = "row-1") state.commit(state.candidate) + state.preview(second, "Second row", rowIdentity = "row-2") - assertEquals("First Movie", state.content?.title) - assertEquals("https://art/first.jpg", state.content?.heroBackdropUrl) + assertFalse(state.hasSettledRealFocus) + + state.commit(state.candidate) + assertTrue(state.hasSettledRealFocus) } @Test diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt index 45c7bfef6..f682976a4 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt @@ -1,30 +1,174 @@ package org.prairieserver.prairie.tv.ui.components import org.prairieserver.prairie.model.catalog.OverlaySummary +import org.prairieserver.prairie.model.section.SectionItem import kotlin.test.Test import kotlin.test.assertEquals class TvFocusMarqueeModelTest { - - @Test fun qualityBadgesPreserveDolbyVisionAndAtmos() { - val summary = OverlaySummary( - resolution = "2160p", - hdr = "Dolby Vision", - audio = "TrueHD Atmos", + @Test + fun movieHeroPrioritizesEditorialMetadataAndOmitsStreamQuality() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "movie-1", + type = "movie", + title = "Arrival", + year = 2016, + genres = listOf("Science Fiction"), + ratingImdb = 7.9, + contentRating = "PG-13", + durationSeconds = 6_960.0, + overlaySummary = OverlaySummary( + resolution = "2160p", + hdr = "Dolby Vision", + audio = "TrueHD Atmos", + ), + ), + rowTitle = "Popular", ) + assertEquals(listOf("PG-13"), content.badges) assertEquals( - listOf("4K", "DOLBY VISION", "ATMOS"), - TvMarqueeContent.qualityBadges(summary), + listOf("2016", "1h 56m", "7.9", "Science Fiction"), + content.metaParts, ) } - @Test fun qualityBadgesPreserveHdr10AndDtsHd() { - val summary = OverlaySummary(hdr = "HDR10", audio = "DTS-HD") + @Test + fun episodeHeroUsesSeriesTitleAndEditorialEpisodeMetadata() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "episode-1", + type = "episode", + title = "Long, Long Time", + seriesTitle = "The Last of Us", + seasonNumber = 1, + episodeNumber = 3, + ratingImdb = 8.6, + contentRating = "TV-MA", + durationSeconds = 4_560.0, + overlaySummary = OverlaySummary( + resolution = "1080p", + audio = "EAC3", + ), + ), + rowTitle = "Continue Watching", + ) + assertEquals("The Last of Us", content.title) + assertEquals(listOf("TV-MA"), content.badges) assertEquals( - listOf("HDR10", "DTS-HD"), - TvMarqueeContent.qualityBadges(summary), + listOf("S1 E3", "Long, Long Time", "1h 16m", "8.6"), + content.metaParts, ) } + + @Test + fun missingEditorialMetadataProducesNoEmptyTokensOrBadges() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "movie-2", + type = "movie", + title = "Untitled", + overlaySummary = OverlaySummary( + resolution = "2160p", + hdr = "HDR10", + audio = "Atmos", + ), + ), + rowTitle = "Recently Added", + ) + + assertEquals(emptyList(), content.badges) + assertEquals(emptyList(), content.metaParts) + } + + @Test + fun invalidRatingsAndDurationsAreOmittedFromTvMetadata() { + listOf( + Double.NaN, + Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY, + 0.0, + -1.0, + 11.0, + ).forEachIndexed { index, invalid -> + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "invalid-$index", + type = "movie", + title = "Invalid", + ratingImdb = invalid, + durationSeconds = invalid, + ), + rowTitle = "Invalid", + ) + + assertEquals(emptyList(), content.metaParts) + } + } + + @Test + fun invalidRatingDoesNotHideValidTvRuntime() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "runtime-with-invalid-rating", + type = "movie", + title = "Movie", + ratingImdb = Double.NaN, + durationSeconds = 7_200.0, + ), + rowTitle = "Row", + ) + + assertEquals(listOf("2h"), content.metaParts) + } + + @Test + fun validRatingDoesNotHideInvalidTvRuntime() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "rating-with-invalid-runtime", + type = "movie", + title = "Movie", + ratingImdb = 8.4, + durationSeconds = Double.NaN, + ), + rowTitle = "Row", + ) + + assertEquals(listOf("8.4"), content.metaParts) + } + + @Test + fun catalogRuntimeWinsOverPlaybackDurationOnTv() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "movie-runtime", + type = "movie", + title = "Movie", + runtime = 125, + durationSeconds = 60.0, + ), + rowTitle = "Row", + ) + + assertEquals(listOf("2h 5m"), content.metaParts) + } + + @Test + fun invalidCatalogRuntimeFallsBackToPlaybackDurationOnTv() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "movie-runtime-fallback", + type = "movie", + title = "Movie", + runtime = 0, + durationSeconds = 6_960.0, + ), + rowTitle = "Row", + ) + + assertEquals(listOf("1h 56m"), content.metaParts) + } } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvOptionDialogPositionProviderTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvOptionDialogPositionProviderTest.kt new file mode 100644 index 000000000..e9c7c07ee --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvOptionDialogPositionProviderTest.kt @@ -0,0 +1,48 @@ +package org.prairieserver.prairie.tv.ui.components + +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvOptionDialogPositionProviderTest { + + @Test + fun `centers tall episode actions in the window regardless of trigger position`() { + val provider = TvOptionDialogWindowPositionProvider() + val window = IntSize(width = 1920, height = 1080) + val popup = IntSize(width = 600, height = 680) + + val nearTop = provider.calculatePosition( + anchorBounds = IntRect(left = 80, top = 100, right = 240, bottom = 180), + windowSize = window, + layoutDirection = LayoutDirection.Ltr, + popupContentSize = popup, + ) + val nearBottom = provider.calculatePosition( + anchorBounds = IntRect(left = 900, top = 850, right = 1100, bottom = 950), + windowSize = window, + layoutDirection = LayoutDirection.Ltr, + popupContentSize = popup, + ) + + assertEquals(IntOffset(x = 660, y = 200), nearTop) + assertEquals(nearTop, nearBottom) + } + + @Test + fun `keeps oversized dialog origin inside the viewport`() { + val provider = TvOptionDialogWindowPositionProvider() + + val position = provider.calculatePosition( + anchorBounds = IntRect(left = 400, top = 700, right = 700, bottom = 800), + windowSize = IntSize(width = 1280, height = 720), + layoutDirection = LayoutDirection.Rtl, + popupContentSize = IntSize(width = 1400, height = 800), + ) + + assertEquals(IntOffset.Zero, position) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePrefetchPolicyTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePrefetchPolicyTest.kt new file mode 100644 index 000000000..7a644c18e --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePrefetchPolicyTest.kt @@ -0,0 +1,97 @@ +package org.prairieserver.prairie.tv.ui.components + +import org.prairieserver.prairie.model.section.SectionItem +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TvSkylinePrefetchPolicyTest { + private val items = listOf("a", "b", "c", "d", "e").map(::item) + + @Test + fun rapidFocusBeforeMarqueeSettlementStartsNoNeighborWork() { + assertTrue( + settledPrefetchItems( + items = items, + rawFocusedContentId = "d", + settledContentId = "b", + ).isEmpty(), + ) + } + + @Test + fun settledFocusReturnsOnlyTwoNeighborsPerSide() { + assertEquals( + listOf("a", "b", "d", "e"), + settledPrefetchItems( + items = items, + rawFocusedContentId = "c", + settledContentId = "c", + ).map { it.contentId }, + ) + } + + @Test + fun firstCardReturnsOnlyFollowingNeighbors() { + assertEquals( + listOf("b", "c"), + settledPrefetchItems( + items = items, + rawFocusedContentId = "a", + settledContentId = "a", + ).map { it.contentId }, + ) + } + + @Test + fun missingSettledIdentityStartsNoNeighborWork() { + assertTrue( + settledPrefetchItems( + items = items, + rawFocusedContentId = "missing", + settledContentId = "missing", + ).isEmpty(), + ) + } + + @Test + fun sameContentInDifferentRowWaitsForRowQualifiedSettlement() { + assertEquals( + null, + settledFocusIdentity( + rawRowIndex = 1, + rawFocusedContentId = "a", + rawFocusedMarqueeId = "row-1#a", + settledMarqueeId = "row-0#a", + ), + ) + assertEquals( + TvSkylineSettledFocus(rowIndex = 1, contentId = "a"), + settledFocusIdentity( + rawRowIndex = 1, + rawFocusedContentId = "a", + rawFocusedMarqueeId = "row-1#a", + settledMarqueeId = "row-1#a", + ), + ) + } + + @Test + fun unsettledFocusHasNoPrefetchIdentity() { + assertEquals( + null, + settledFocusIdentity( + rawRowIndex = 1, + rawFocusedContentId = "b", + rawFocusedMarqueeId = "row-1#b", + settledMarqueeId = "row-0#a", + ), + ) + } + + private fun item(id: String) = SectionItem( + contentId = id, + type = "movie", + title = id, + ) +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineUpNavigationTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineUpNavigationTest.kt new file mode 100644 index 000000000..e8836d4e3 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineUpNavigationTest.kt @@ -0,0 +1,59 @@ +package org.prairieserver.prairie.tv.ui.components + +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvSkylineUpNavigationTest { + + @Test + fun heldUpStopsOnFirstContentRow() { + assertEquals( + TvSkylineUpAction.StayInContent, + tvSkylineUpAction( + currentRow = 0, + rowCount = 6, + isRepeat = true, + relocationInFlight = false, + ), + ) + } + + @Test + fun freshUpFromFirstContentRowMayEnterMenu() { + assertEquals( + TvSkylineUpAction.EnterMenu, + tvSkylineUpAction( + currentRow = 0, + rowCount = 6, + isRepeat = false, + relocationInFlight = false, + ), + ) + } + + @Test + fun repeatedInputDuringOffscreenRelocationIsConsumed() { + assertEquals( + TvSkylineUpAction.StayInContent, + tvSkylineUpAction( + currentRow = 4, + rowCount = 6, + isRepeat = true, + relocationInFlight = true, + ), + ) + } + + @Test + fun ordinaryUpWithinRowsTriesExactlyOnePreviousRow() { + assertEquals( + TvSkylineUpAction.TryPreviousRow, + tvSkylineUpAction( + currentRow = 4, + rowCount = 6, + isRepeat = false, + relocationInFlight = false, + ), + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarFocusRoutingTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarFocusRoutingTest.kt index 6f2480453..32f08fae1 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarFocusRoutingTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarFocusRoutingTest.kt @@ -1,6 +1,7 @@ package org.prairieserver.prairie.tv.ui.screens.calendar import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -20,8 +21,33 @@ class TvCalendarFocusRoutingTest { assertFalse(shouldReturnCalendarFocusToControls(null, 2, false)) } + @Test + fun controlsReturnToShellMenuTarget() { + assertEquals( + CalendarUpFallbackAction.EnterMenu, + calendarUpFallbackAction( + focusedShelfIndex = null, + firstFocusableShelfIndex = 2, + isReturningToControls = false, + ), + ) + } + @Test fun returnInFlightDoesNotRestartChoreography() { assertFalse(shouldReturnCalendarFocusToControls(2, 2, true)) } + + @Test + fun heldUpOnCalendarControlsStaysInContent() { + assertEquals( + CalendarUpFallbackAction.StayInContent, + calendarUpFallbackAction( + focusedShelfIndex = null, + firstFocusableShelfIndex = 2, + isReturningToControls = false, + isRepeat = true, + ), + ) + } } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDirectorCreditSourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDirectorCreditSourceTest.kt new file mode 100644 index 000000000..4150ab524 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDirectorCreditSourceTest.kt @@ -0,0 +1,27 @@ +package org.prairieserver.prairie.tv.ui.screens.detail + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class TvDirectorCreditSourceTest { + private val hero = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt", + ).readText() + private val screen = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt", + ).readText() + + @Test + fun tvMovieHeroUsesSharedDirectorCredit() { + assertTrue(screen.contains("directorText = movieDirectorCredit(detail)")) + } + + @Test + fun tvCreditStaysBetweenTranslationAndFacts() { + val translation = hero.indexOf("translation?.invoke()") + val director = hero.indexOf("directorText?.takeIf") + val facts = hero.indexOf("if (factsLine.isNotEmpty())", startIndex = director) + assertTrue(translation >= 0 && translation < director && director < facts) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailSubtitlePreferenceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailSubtitlePreferenceTest.kt new file mode 100644 index 000000000..850d33d8a --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailSubtitlePreferenceTest.kt @@ -0,0 +1,260 @@ +package org.prairieserver.prairie.tv.ui.screens.detail + +import androidx.lifecycle.viewModelScope +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import org.prairieserver.prairie.domain.settings.ProfileSettingsController +import org.prairieserver.prairie.model.settings.EffectiveSettingValue +import org.prairieserver.prairie.model.settings.EffectiveSettingValuesResponse +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.model.settings.SettingScope +import org.prairieserver.prairie.model.settings.SettingsContractCapabilities +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.api.CatalogApi +import org.prairieserver.prairie.network.api.DefaultMetadataAiApi +import org.prairieserver.prairie.network.api.PersonalDataApi +import org.prairieserver.prairie.network.api.ProfileApi +import org.prairieserver.prairie.network.api.SettingsApi +import org.prairieserver.prairie.network.api.SettingsCapabilitiesResult +import org.prairieserver.prairie.repository.CatalogRepository +import org.prairieserver.prairie.repository.MetadataAiRepository +import org.prairieserver.prairie.repository.PersonalDataRepository +import org.prairieserver.prairie.repository.ProfileRepository +import org.prairieserver.prairie.repository.SettingsRepository +import org.prairieserver.prairie.tv.testing.FakePlayerSettingsStore +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The detail row's "Auto" subtitle preview must resolve the same preferences + * playback will use. + * + * The TV settings screen writes `playback.subtitle_language` / `subtitle_mode` + * / `show_forced_subtitles` at `scope=profile` through + * [ProfileSettingsController]. The server does not mirror a canonical write + * back into the `user_profiles` columns `GET /profiles` serves, so reading + * those columns here previewed the preference from *before* the user's last + * edit while `TvVideoPlaybackStarter` — which reads WatchDetail's + * server-resolved `effective_*` fields — played the new one. Same screen, same + * item, two answers. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TvItemDetailSubtitlePreferenceTest { + + @Test + fun `the auto preview reads the canonical values, not the stale profile columns`() = + runDetailTest { + val viewModel = createViewModel( + settingsApi = FakeSettingsApi( + effective = effectiveOf( + SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE to JsonPrimitive("ja"), + SettingKeys.PLAYBACK_SUBTITLE_MODE to JsonPrimitive("always"), + SettingKeys.PLAYBACK_SHOW_FORCED_SUBTITLES to JsonPrimitive(false), + ), + ), + // What GET /profiles still serves: the pre-edit columns. + profileSubtitleLanguage = "en", + profileSubtitleMode = "off", + profileShowForced = true, + ) + awaitState(viewModel) { it.preferredSubtitleLanguage != null } + + val state = viewModel.uiState.value + assertEquals("ja", state.preferredSubtitleLanguage) + assertEquals("always", state.subtitleMode) + assertEquals(false, state.showForcedSubtitles) + } + + @Test + fun `a resolved empty language reads as no preference, not as no subtitles`() = + runDetailTest { + // The canonical snapshot spells "no preference" as ""; the Auto + // preview spells it as null and reads "" as "turn subtitles off". + val viewModel = createViewModel( + settingsApi = FakeSettingsApi( + effective = effectiveOf( + SettingKeys.PLAYBACK_SUBTITLE_MODE to JsonPrimitive("always"), + ), + ), + profileSubtitleLanguage = "de", + ) + awaitState(viewModel) { it.subtitleMode == "always" } + + assertEquals(null, viewModel.uiState.value.preferredSubtitleLanguage) + } + + @Test + fun `the profile columns stay the fallback when the contract cannot be resolved`() = + runDetailTest { + val viewModel = createViewModel( + settingsApi = FakeSettingsApi( + capabilities = SettingsCapabilitiesResult.ServerUpgradeRequired, + ), + profileSubtitleLanguage = "de", + profileSubtitleMode = "always", + profileShowForced = false, + ) + awaitState(viewModel) { it.preferredSubtitleLanguage != null } + + val state = viewModel.uiState.value + assertEquals("de", state.preferredSubtitleLanguage) + assertEquals("always", state.subtitleMode) + assertEquals(false, state.showForcedSubtitles) + } + + // ------------------------------------------------------------------ + + private val createdViewModels = mutableListOf() + + private fun runDetailTest(block: suspend () -> Unit) = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + try { + block() + } finally { + // Cancel viewModelScope work BEFORE resetting Main; a coroutine + // still alive when the next test calls setMain throws from + // TestMainDispatcher. + createdViewModels.forEach { it.viewModelScope.cancel() } + createdViewModels.clear() + Dispatchers.resetMain() + } + } + + private fun createViewModel( + settingsApi: SettingsApi, + profileSubtitleLanguage: String? = null, + profileSubtitleMode: String? = null, + profileShowForced: Boolean? = null, + ): TvItemDetailViewModel { + val client = detailClient(profileSubtitleLanguage, profileSubtitleMode, profileShowForced) + return TvItemDetailViewModel( + catalogRepository = CatalogRepository(CatalogApi(client)), + personalDataRepository = PersonalDataRepository(PersonalDataApi(client)), + playerSettingsStore = FakePlayerSettingsStore(), + profileRepository = ProfileRepository(ProfileApi(client), FakeTokenManager()), + profileSettings = ProfileSettingsController(SettingsRepository(settingsApi)), + metadataAiRepository = MetadataAiRepository(DefaultMetadataAiApi(client)), + contentId = CONTENT_ID, + ).also { createdViewModels += it } + } + + private suspend fun awaitState( + viewModel: TvItemDetailViewModel, + predicate: (TvItemDetailUiState) -> Boolean, + ) { + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(30_000) { + while (!predicate(viewModel.uiState.value)) { + delay(10) + } + } + } + } + + private fun effectiveOf(vararg values: Pair) = + EffectiveSettingValuesResponse( + settings = values.map { (key, value) -> + EffectiveSettingValue( + key = key, + value = value, + source = SettingScope.PROFILE.wire, + ) + }, + ) + + private class FakeSettingsApi( + private val capabilities: SettingsCapabilitiesResult = + SettingsCapabilitiesResult.Available(SettingsContractCapabilities(revision = 1)), + private val effective: EffectiveSettingValuesResponse = EffectiveSettingValuesResponse(), + ) : SettingsApi(HttpClient()) { + override suspend fun getContractCapabilities(): SettingsCapabilitiesResult = capabilities + + override suspend fun getEffectiveValues( + keys: List, + libraryIds: List, + seriesIds: List, + ): ApiResult = ApiResult.Success(effective) + } + + private class FakeTokenManager : TokenManager { + override val sessionExpired: SharedFlow = MutableSharedFlow() + override suspend fun getAccessToken(): String = "token" + override suspend fun getRefreshToken(): String? = null + override suspend fun saveTokens(accessToken: String, refreshToken: String, expiresIn: Long) = Unit + override suspend fun clearTokens() = Unit + override suspend fun invalidateSession() = Unit + override suspend fun getProfileId(): String = PROFILE_ID + override suspend fun setProfileId(profileId: String?) = Unit + override suspend fun getProfileToken(): String? = null + override suspend fun setProfileToken(token: String?) = Unit + override suspend fun getServerUrl(): String = "https://tv.example" + override suspend fun setServerUrl(url: String) = Unit + override suspend fun getCurrentServerId(): String = "server" + override suspend fun switchActiveServer(serverId: String?) = Unit + override suspend fun signOutCurrentServer() = Unit + } + + private fun detailClient( + subtitleLanguage: String?, + subtitleMode: String?, + showForced: Boolean?, + ): HttpClient = HttpClient( + MockEngine { request -> + when (request.url.encodedPath) { + "/api/v1/profiles" -> respond( + content = buildString { + append("""{"profiles":[{"id":"$PROFILE_ID","name":"Profile"""") + subtitleLanguage?.let { append(""","subtitle_language":"$it"""") } + subtitleMode?.let { append(""","subtitle_mode":"$it"""") } + showForced?.let { append(""","show_forced_subtitles":$it""") } + append("}]}") + }, + status = HttpStatusCode.OK, + headers = JSON_HEADERS, + ) + "/api/v1/catalog/items/$CONTENT_ID" -> respond( + content = """ + {"content_id":"$CONTENT_ID","type":"movie","title":"Detail"} + """.trimIndent(), + status = HttpStatusCode.OK, + headers = JSON_HEADERS, + ) + else -> respond( + content = """{"error":"not_found","message":"not found"}""", + status = HttpStatusCode.NotFound, + headers = JSON_HEADERS, + ) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + + private companion object { + const val CONTENT_ID = "movie-1" + const val PROFILE_ID = "profile-1" + val JSON_HEADERS = headersOf(HttpHeaders.ContentType, "application/json") + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSeasonPresentationLabelTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSeasonPresentationLabelTest.kt new file mode 100644 index 000000000..1d833946f --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSeasonPresentationLabelTest.kt @@ -0,0 +1,55 @@ +package org.prairieserver.prairie.tv.ui.screens.detail + +import org.prairieserver.prairie.model.catalog.ItemDetail +import org.prairieserver.prairie.model.catalog.Season +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvSeasonPresentationLabelTest { + @Test + fun pickerLabelsSeasonZeroWithoutSpecialsFlagAsSpecials() { + assertEquals( + "Specials", + tvSeasonPickerLabel(Season(contentId = "specials", seasonNumber = 0)), + ) + } + + @Test + fun pickerLabelsNonzeroSeasonWithSpecialsFlagAsSpecials() { + assertEquals( + "Specials", + tvSeasonPickerLabel( + Season(contentId = "bonus", seasonNumber = 99, isSpecials = true), + ), + ) + } + + @Test + fun explicitSpecialsEpisodeUsesSpecialsHeader() { + val detail = ItemDetail( + contentId = "episode-special", + type = "episode", + title = "Bonus", + seasonNumber = 0, + ) + + assertEquals("Specials", episodeEyebrowLabel(detail, TvItemDetailUiState())) + } + + @Test + fun specialsOnlySeriesUsesSpecialsHeaderAndPickerLabel() { + val specials = Season(contentId = "specials", seasonNumber = 0) + val detail = ItemDetail( + contentId = "series", + type = "series", + title = "Series", + ) + val state = TvItemDetailUiState( + seasons = listOf(specials), + selectedSeason = 0, + ) + + assertEquals("Specials", episodeEyebrowLabel(detail, state)) + assertEquals("Specials", tvSeasonPickerLabel(specials)) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvStarringOverlaySourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvStarringOverlaySourceTest.kt new file mode 100644 index 000000000..4d311ea5c --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvStarringOverlaySourceTest.kt @@ -0,0 +1,33 @@ +package org.prairieserver.prairie.tv.ui.screens.detail + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvStarringOverlaySourceTest { + private val hero = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt", + ).readText() + private val screen = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt", + ).readText() + private val metadata = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt", + ).readText() + private val presentationSources = listOf(hero, screen, metadata) + + @Test + fun tvDetailDoesNotDeriveOrRenderDuplicatedStarringOverlay() { + assertFalse( + presentationSources.any { source -> + source.contains("starring", ignoreCase = true) + }, + ) + } + + @Test + fun tvDetailStillRendersTheFullCastSection() { + assertTrue(screen.contains("TvCastCrewSection(")) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryReviewWiringSourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryReviewWiringSourceTest.kt new file mode 100644 index 000000000..40742e6d4 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryReviewWiringSourceTest.kt @@ -0,0 +1,42 @@ +package org.prairieserver.prairie.tv.ui.screens.library + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class TvLibraryReviewWiringSourceTest { + private val detailScreen = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailScreen.kt", + ).readText() + private val mainShell = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt", + ).readText() + + @Test + fun alphabetAndCalendarForwardContentUpFallbackToTheShell() { + val alphabetTab = extractBetween( + source = detailScreen, + startAnchor = "TvLibraryTab.Alphabet -> LibraryTab(", + endAnchor = "TvLibraryTab.RecentlyAdded ->", + ) + val calendarScreen = extractBetween( + source = mainShell, + startAnchor = "TvCalendarScreen(", + endAnchor = "composable(TvMainRoute.Browse.route)", + ) + + assertTrue(alphabetTab.contains("onContentUpFallbackChanged = onContentUpFallbackChanged")) + assertTrue(calendarScreen.contains("onContentUpFallbackChanged = onContentUpFallback")) + } + + private fun extractBetween(source: String, startAnchor: String, endAnchor: String): String { + val start = source.indexOf(startAnchor) + assertTrue(start >= 0, "Missing start anchor: $startAnchor") + + val contentStart = start + startAnchor.length + val end = source.indexOf(endAnchor, contentStart) + assertTrue(end >= 0, "Missing end anchor: $endAnchor") + + return source.substring(contentStart, end) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/SubtitleTransactionIntegrationTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/SubtitleTransactionIntegrationTest.kt index 8ee2e6fdf..ac0f575a7 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/SubtitleTransactionIntegrationTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/SubtitleTransactionIntegrationTest.kt @@ -11,19 +11,26 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json import java.util.Collections +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.int @@ -80,6 +87,30 @@ import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeSource + +private suspend fun awaitHarnessCondition( + transactionScheduler: TestCoroutineScheduler, + cleanupScheduler: TestCoroutineScheduler, + timeoutMillis: Long, + condition: suspend () -> Boolean, +) { + val started = TimeSource.Monotonic.markNow() + while (!condition()) { + // runTest already owns and drives its transaction scheduler. Driving it + // again from another thread can execute nominally single-threaded test + // tasks concurrently. Only a genuinely separate manager-cleanup + // scheduler needs manual progress here. + if (cleanupScheduler !== transactionScheduler) { + cleanupScheduler.runCurrent() + } + if (started.elapsedNow() >= timeoutMillis.milliseconds) { + throw AssertionError("Timed out waiting for subtitle transaction cleanup") + } + yield() + } +} @OptIn(ExperimentalCoroutinesApi::class) @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") @@ -131,6 +162,70 @@ class SubtitleTransactionIntegrationTest { harness.assertNoOrphans() } + @Test + fun `cleanup wait advances the manager-owned test scheduler`() = runTest { + val cleanupDispatcher = StandardTestDispatcher() + val cleanupJob = SupervisorJob() + val cleanupScope = CoroutineScope(cleanupJob + cleanupDispatcher) + try { + val harness = harness( + replanResponse = { _, _ -> response(sidecarPlan("s2", FILE_ID, B_INDEX)) }, + committedSessionCleanupScope = cleanupScope, + committedSessionCleanupScheduler = cleanupDispatcher.scheduler, + ) + harness.start(sidecarA) + + harness.adapter.select(sidecarB) + runCurrent() + harness.awaitReplans(1) + harness.awaitAdopted("s2") + harness.mountPending( + expectedSessionId = "s2", + tracks = listOf( + harness.sidecarMountedTrack( + expectedSessionId = "s2", + serverIndex = B_INDEX, + playerIndex = 9, + ), + ), + ) + runCurrent() + + harness.awaitStopped("s1") + + assertEquals(mapOf("s1" to 1), harness.stopCounts()) + harness.assertNoOrphans() + } finally { + cleanupJob.cancelAndJoin() + } + } + + @Test + fun `cleanup wait never drives the shared transaction scheduler concurrently`() = runTest { + val firstTaskRunning = AtomicBoolean(false) + val overlapObserved = AtomicBoolean(false) + val completed = AtomicBoolean(false) + + backgroundScope.launch { + firstTaskRunning.set(true) + Thread.sleep(100) + firstTaskRunning.set(false) + } + backgroundScope.launch { + overlapObserved.set(firstTaskRunning.get()) + completed.set(true) + } + + awaitHarnessCondition( + transactionScheduler = testScheduler, + cleanupScheduler = testScheduler, + timeoutMillis = EVENT_TIMEOUT_MS, + condition = completed::get, + ) + + assertFalse(overlapObserved.get()) + } + @Test fun `off supersedes an in-flight sidecar and replans from the committed session`() = runTest { val firstEntered = CompletableDeferred() @@ -156,6 +251,10 @@ class SubtitleTransactionIntegrationTest { harness.awaitReplans(2) harness.awaitStopped("s2") harness.awaitAdopted("s3") + assertTrue( + testScheduler.currentTime < EVENT_TIMEOUT_MS, + "Adoption reached the pending Media3 mount deadline before the test could mount it.", + ) runCurrent() assertEquals(listOf("s1", "s1"), harness.replanBaseSessions) @@ -345,13 +444,21 @@ class SubtitleTransactionIntegrationTest { private fun TestScope.harness( replanResponse: suspend (Int, JsonObject) -> PlaybackDecisionResponseV3, + committedSessionCleanupScope: CoroutineScope = backgroundScope, + committedSessionCleanupScheduler: TestCoroutineScheduler = testScheduler, ): Harness = Harness( scope = backgroundScope, + transactionScheduler = testScheduler, + committedSessionCleanupScope = committedSessionCleanupScope, + committedSessionCleanupScheduler = committedSessionCleanupScheduler, replanResponse = replanResponse, ) private class Harness( private val scope: CoroutineScope, + private val transactionScheduler: TestCoroutineScheduler, + committedSessionCleanupScope: CoroutineScope, + private val committedSessionCleanupScheduler: TestCoroutineScheduler, private val replanResponse: suspend (Int, JsonObject) -> PlaybackDecisionResponseV3, ) { val stoppedSessions: MutableList = @@ -368,9 +475,7 @@ class SubtitleTransactionIntegrationTest { Collections.synchronizedMap(mutableMapOf()) val media3Selections = mutableListOf() - private val stoppedEvents = Channel(Channel.UNLIMITED) private val replanEvents = Channel(Channel.UNLIMITED) - private val adoptedEvents = Channel(Channel.UNLIMITED) private val persistenceEvents = Channel(Channel.UNLIMITED) private val startIndex = AtomicInteger() private val replanIndex = AtomicInteger() @@ -412,7 +517,6 @@ class SubtitleTransactionIntegrationTest { path.startsWith("/api/v1/playback/") -> { val sessionId = path.substringAfterLast('/') stoppedSessions += sessionId - stoppedEvents.send(sessionId) null } else -> null @@ -429,7 +533,7 @@ class SubtitleTransactionIntegrationTest { val manager = PlaybackSessionManager( playbackRepository = PlaybackRepository(PlaybackApi(client)), tokenManager = IntegrationTokenManager, - committedSessionCleanupScope = scope, + committedSessionCleanupScope = committedSessionCleanupScope, ) val lifecycle = PlaybackSessionLifecycle( sessionManager = manager, @@ -515,7 +619,6 @@ class SubtitleTransactionIntegrationTest { if (adopted && adoption.isCurrent()) { adoptedPlaybackRows[candidate.session.sessionId] = adoption.playback.subtitleTracks - adoptedEvents.send(candidate.session.sessionId) TvSubtitleAdoptionResult.Adopted } else { TvSubtitleAdoptionResult.Superseded @@ -672,13 +775,12 @@ class SubtitleTransactionIntegrationTest { suspend fun awaitStopped(sessionId: String) { if (sessionId in stoppedSessions) return - withContext(Dispatchers.Default) { - withTimeout(EVENT_TIMEOUT_MS) { - while (stoppedEvents.receive() != sessionId) { - // Drain unrelated cleanup completions. - } - } - } + awaitHarnessCondition( + transactionScheduler = transactionScheduler, + cleanupScheduler = committedSessionCleanupScheduler, + timeoutMillis = EVENT_TIMEOUT_MS, + condition = { sessionId in stoppedSessions }, + ) } suspend fun awaitReplans(count: Int) { @@ -690,14 +792,15 @@ class SubtitleTransactionIntegrationTest { } suspend fun awaitAdopted(sessionId: String) { - if (lifecycle.activeSessionId() == sessionId) return - withContext(Dispatchers.Default) { - withTimeout(5_000) { - while (adoptedEvents.receive() != sessionId) { - // Drain unrelated adoption completions. - } - } - } + awaitHarnessCondition( + transactionScheduler = transactionScheduler, + cleanupScheduler = transactionScheduler, + timeoutMillis = EVENT_TIMEOUT_MS, + condition = { + manager.activeSessionIdForTest() == sessionId && + lifecycle.activeSessionId() == sessionId + }, + ) } suspend fun awaitPersistence(count: Int) { @@ -717,13 +820,12 @@ class SubtitleTransactionIntegrationTest { } suspend fun assertNoOrphans() { - withContext(Dispatchers.Default) { - withTimeout(EVENT_TIMEOUT_MS) { - while (manager.orphanedSessionIdsForTest().isNotEmpty()) { - kotlinx.coroutines.yield() - } - } - } + awaitHarnessCondition( + transactionScheduler = transactionScheduler, + cleanupScheduler = committedSessionCleanupScheduler, + timeoutMillis = EVENT_TIMEOUT_MS, + condition = { manager.orphanedSessionIdsForTest().isEmpty() }, + ) assertEquals(emptySet(), manager.orphanedSessionIdsForTest()) } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomDeliveryKeySourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomDeliveryKeySourceTest.kt new file mode 100644 index 000000000..a5681b2ea --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomDeliveryKeySourceTest.kt @@ -0,0 +1,47 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class TvRoomDeliveryKeySourceTest { + private val reportingStartAnchor = "// Drift reporting loop" + private val reportingEndAnchor = "// ready / buffering during the waiting barrier." + + private val controllerSource = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomSyncController.kt", + ).readText() + + @Test + fun stateReportRequiresAnExplicitNonNullDeliveryKey() { + val reportingBlock = reportingBlock(controllerSource) + val guardIndex = reportingBlock.indexOf("deliveryKey != null") + val stateReportIndex = reportingBlock.indexOf("repository.stateReport(") + + assertFalse(reportingBlock.contains("deliveryKey!!")) + assertTrue(guardIndex >= 0, "Drift reporting block must guard deliveryKey explicitly.") + assertTrue(stateReportIndex >= 0, "Drift reporting block must report state.") + assertTrue(guardIndex < stateReportIndex) + } + + @Test + fun reportingBlockFailsClosedWhenEitherAnchorIsMissing() { + assertFailsWith { + reportingBlock(controllerSource.replace(reportingStartAnchor, "")) + } + assertFailsWith { + reportingBlock(controllerSource.replace(reportingEndAnchor, "")) + } + } + + private fun reportingBlock(source: String): String { + val startAnchorIndex = source.indexOf(reportingStartAnchor) + assertTrue(startAnchorIndex >= 0, "Missing drift-reporting start anchor.") + val blockStart = startAnchorIndex + reportingStartAnchor.length + val endAnchorIndex = source.indexOf(reportingEndAnchor, startIndex = blockStart) + assertTrue(endAnchorIndex >= blockStart, "Missing drift-reporting end anchor.") + return source.substring(blockStart, endAnchorIndex) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAspectModeWiringSourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAspectModeWiringSourceTest.kt new file mode 100644 index 000000000..1420d6295 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAspectModeWiringSourceTest.kt @@ -0,0 +1,42 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class TvSubtitleAspectModeWiringSourceTest { + private fun source(path: String): String { + val moduleRelative = File("src/androidMain/kotlin/$path") + val projectRelative = File("androidTvApp/src/androidMain/kotlin/$path") + return (moduleRelative.takeIf(File::exists) ?: projectRelative).readText() + } + + private fun playerViewUpdateBlock(source: String): String { + val factoryAnchor = ") as PlayerView).apply {" + val updateAnchor = "update = { view ->" + val endAnchor = "if (!isInPictureInPictureMode" + val factoryIndex = source.indexOf(factoryAnchor) + require(factoryIndex >= 0) { "PlayerView factory anchor is missing" } + val androidViewIndex = source.lastIndexOf("AndroidView(", factoryIndex) + require(androidViewIndex >= 0) { "Enclosing AndroidView is missing" } + val updateIndex = source.indexOf(updateAnchor, factoryIndex) + require(updateIndex > factoryIndex) { "PlayerView update lambda is missing or misordered" } + val endIndex = source.indexOf(endAnchor, updateIndex) + require(endIndex > updateIndex) { "PlayerView update lambda terminator is missing or misordered" } + return source.substring(updateIndex, endIndex) + } + + @Test + fun playerViewReconcilesSubtitlesAfterFillModeUpdate() { + val source = source( + "org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt" + ) + val update = playerViewUpdateBlock(source) + + val aspectCall = "applyPlayerViewVideoFillMode(view, state.videoFillMode)" + val subtitleCall = "subtitleManager.syncSubtitleVideoBounds(view)" + assertTrue(update.contains(aspectCall)) + assertTrue(update.contains(subtitleCall)) + assertTrue(update.indexOf(aspectCall) < update.indexOf(subtitleCall)) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAspectSyncWiringTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAspectSyncWiringTest.kt new file mode 100644 index 000000000..6fdd71f42 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAspectSyncWiringTest.kt @@ -0,0 +1,20 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class TvSubtitleAspectSyncWiringTest { + @Test + fun televisionSubtitleManagerUsesTelevisionPresentation() { + val source = source("org/prairieserver/prairie/tv/di/AndroidTvModule.kt") + + assertTrue(source.contains("SubtitleManager(\n libassBridge = get(),\n presentation = AndroidSubtitlePresentation.Television,")) + } + + private fun source(path: String): String { + val moduleRelative = File("src/androidMain/kotlin/$path") + val projectRelative = File("androidTvApp/src/androidMain/kotlin/$path") + return (moduleRelative.takeIf(File::exists) ?: projectRelative).readText() + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequestTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequestTest.kt new file mode 100644 index 000000000..0c084b756 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequestTest.kt @@ -0,0 +1,117 @@ +package org.prairieserver.prairie.tv.ui.screens.recommendations + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TvForYouEntryRequestTest { + + @Test + fun repeatedSelectionStillCreatesANewRequest() { + val first = TvForYouEntryRequest().next(SavedListSelection.Watchlist) + val second = first.next(SavedListSelection.Watchlist) + + assertEquals(1, first.sequence) + assertEquals(2, second.sequence) + assertEquals(SavedListSelection.Watchlist, second.selection) + } + + @Test + fun recommendationsRequestClearsSavedListSelection() { + val request = TvForYouEntryRequest( + sequence = 4, + selection = SavedListSelection.Favorites, + ).next(null) + + assertEquals(5, request.sequence) + assertNull(request.selection) + } + + @Test + fun topLevelForYouRequestClearsSavedListSelection() { + val request = TvForYouEntryRequest( + sequence = 9, + selection = SavedListSelection.Watchlist, + ).nextForTopLevelForYou() + + assertEquals(10, request.sequence) + assertNull(request.selection) + } + + @Test + fun unrelatedRecompositionDoesNotOverrideInPageSelection() { + val applied = applyForYouEntryRequest( + currentSelection = SavedListSelection.Favorites, + lastAppliedSequence = 3, + request = TvForYouEntryRequest( + sequence = 3, + selection = SavedListSelection.Watchlist, + ), + ) + + assertEquals(SavedListSelection.Favorites, applied.selection) + assertEquals(3, applied.lastAppliedSequence) + assertFalse(applied.appliedRequest) + } + + @Test + fun newerRequestAppliesRequestedInlineSelection() { + val applied = applyForYouEntryRequest( + currentSelection = null, + lastAppliedSequence = 3, + request = TvForYouEntryRequest( + sequence = 4, + selection = SavedListSelection.Watchlist, + ), + ) + + assertEquals(SavedListSelection.Watchlist, applied.selection) + assertEquals(4, applied.lastAppliedSequence) + assertTrue(applied.appliedRequest) + } + + @Test + fun sameRouteCrossSelectionFocusesNewRequestedPillAfterComposition() = runTest { + val events = mutableListOf() + + val focused = requestForYouEntryFocus( + selection = SavedListSelection.Favorites, + awaitFrame = { events += "frame" }, + requestForYou = { events += "for-you"; true }, + requestWatchlist = { events += "watchlist"; true }, + requestFavorites = { events += "favorites"; true }, + ) + + assertTrue(focused) + assertEquals(listOf("frame", "favorites"), events) + } + + @Test + fun repeatedSameRouteSelectionStillRefocusesRequestedPill() = runTest { + val request = TvForYouEntryRequest( + sequence = 8, + selection = SavedListSelection.Watchlist, + ).next(SavedListSelection.Watchlist) + val applied = applyForYouEntryRequest( + currentSelection = SavedListSelection.Watchlist, + lastAppliedSequence = 8, + request = request, + ) + val events = mutableListOf() + + if (applied.appliedRequest) { + requestForYouEntryFocus( + selection = applied.selection, + awaitFrame = { events += "frame" }, + requestForYou = { events += "for-you"; true }, + requestWatchlist = { events += "watchlist"; true }, + requestFavorites = { events += "favorites"; true }, + ) + } + + assertEquals(listOf("frame", "watchlist"), events) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridgeTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridgeTest.kt new file mode 100644 index 000000000..7cccd151b --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridgeTest.kt @@ -0,0 +1,68 @@ +package org.prairieserver.prairie.tv.ui.screens.recommendations + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvRecommendationsFocusBridgeTest { + + @Test + fun handoffCrossesRowRestorerBeforeTargetingFirstCard() = runTest { + val events = mutableListOf() + + val handled = requestRecommendationRowFocus( + requestRowContainer = { events += "row"; true }, + awaitFrame = { events += "frame" }, + requestFirstCard = { events += "card"; true }, + ) + + assertTrue(handled) + assertEquals(listOf("row", "frame", "card"), events) + } + + @Test + fun rejectedRowHopDoesNotTargetCard() = runTest { + val events = mutableListOf() + + val handled = requestRecommendationRowFocus( + requestRowContainer = { events += "row"; false }, + awaitFrame = { events += "frame" }, + requestFirstCard = { events += "card"; true }, + ) + + assertFalse(handled) + assertEquals(listOf("row"), events) + } + + @Test + fun forYouWithVisibleRowsUsesTheBridge() { + assertTrue( + shouldBridgeRecommendationsDown( + showingRecommendations = true, + hasVisibleRecommendations = true, + ), + ) + } + + @Test + fun savedListsKeepTheirExistingGridNavigation() { + assertFalse( + shouldBridgeRecommendationsDown( + showingRecommendations = false, + hasVisibleRecommendations = true, + ), + ) + } + + @Test + fun loadingOrEmptyForYouDoesNotTargetAnAbsentRow() { + assertFalse( + shouldBridgeRecommendationsDown( + showingRecommendations = true, + hasVisibleRecommendations = false, + ), + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestinationTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestinationTest.kt new file mode 100644 index 000000000..1639b4071 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestinationTest.kt @@ -0,0 +1,49 @@ +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.tv.ui.navigation.TvRoute +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvWatchTogetherDestinationTest { + @Test + fun emptyAndSoloHostRoomsUseLobby() { + assertEquals( + TvRoute.WatchTogetherLobby("room-1").route, + tvWatchTogetherDestination(RoomSnapshot(roomId = "room-1")), + ) + assertEquals( + TvRoute.WatchTogetherLobby("room-1").route, + tvWatchTogetherDestination( + RoomSnapshot( + roomId = "room-1", + selectedContentId = "movie-1", + selfRole = MemberRole.Host, + memberCount = 1, + ), + ), + ) + } + + @Test + fun selectedJoinedRoomUsesSyncedPlayer() { + val room = RoomSnapshot( + roomId = "room-1", + selectedContentId = "movie-1", + selectedFileId = 7, + selfRole = MemberRole.Guest, + memberCount = 2, + anchorPositionSeconds = 12.5, + ) + assertEquals( + TvRoute.Player( + contentId = "movie-1", + fileId = 7, + roomId = "room-1", + resumePositionSeconds = 12.5, + ).route, + tvWatchTogetherDestination(room), + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyContinuitySourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyContinuitySourceTest.kt new file mode 100644 index 000000000..6a74c0602 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyContinuitySourceTest.kt @@ -0,0 +1,35 @@ +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvWatchTogetherLobbyContinuitySourceTest { + private val lobby = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt", + ).readText() + private val detail = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt", + ).readText() + + @Test + fun backAndBrowsePreserveRoomWhileLeaveIsExplicit() { + val backHandler = lobby.substringAfter("BackHandler(enabled = true)") + .substringBefore("Box(") + assertTrue(backHandler.contains("onBack()")) + assertFalse(backHandler.contains("viewModel.leave()")) + assertTrue(lobby.contains("title = \"Browse titles\"")) + assertTrue(lobby.contains("title = \"Leave room\"")) + assertTrue(lobby.contains("viewModel.leave()")) + } + + @Test + fun existingOwnerAuthorityAndSuggestionPathRemain() { + assertTrue(lobby.contains("CloseRoomButton(onClick = viewModel::closeRoom)")) + assertTrue(lobby.contains("viewModel.vote(s.id)")) + assertTrue(lobby.contains("viewModel.promote(s.id)")) + assertTrue(detail.contains("Suggest to Watch Together")) + assertTrue(detail.contains("suggestViewModel.suggest(")) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyErrorTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyErrorTest.kt new file mode 100644 index 000000000..f225377f4 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyErrorTest.kt @@ -0,0 +1,172 @@ +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.prairieserver.prairie.model.watchtogether.AddSuggestionRequest +import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest +import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.PromoteSuggestionRequest +import org.prairieserver.prairie.model.watchtogether.RoomResponse +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.model.watchtogether.SuggestionsResponse +import org.prairieserver.prairie.model.watchtogether.UpdatePolicyRequest +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.api.WatchTogetherApi +import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.watchtogether.RoomSession +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class TvWatchTogetherLobbyErrorTest { + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `rejected lobby operations use the transient repository message path`() = runTest(dispatcher) { + val api = FailingLobbyApi() + val repository = WatchTogetherRepository( + api = api, + authScopeProvider = { AUTH_SCOPE }, + ) + repository.createRoom(CreateRoomRequest()) + val roomSession = RoomSession(repository, backgroundScope, DefaultIdentityTransitionBarrier()) + val viewModel = TvWatchTogetherLobbyViewModel("room-1", repository, roomSession) + val messages = mutableListOf() + val collector = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.errors.toList(messages) + } + + viewModel.vote("suggestion-1") + viewModel.promote("suggestion-1") + repository.reportDeliveryFailure("Socket request rejected") + runCurrent() + + assertEquals( + listOf( + "Voting is disabled", + "Only the host can promote", + "Socket request rejected", + ), + messages, + ) + collector.cancel() + } + + @Test + fun `suggest rejection remains a visible one shot detail message`() = runTest(dispatcher) { + val repository = WatchTogetherRepository( + api = FailingLobbyApi(), + authScopeProvider = { AUTH_SCOPE }, + ) + repository.createRoom(CreateRoomRequest()) + val viewModel = TvSuggestToRoomViewModel(repository) + + viewModel.suggest("movie-1", "movie", "Movie One", null, null) + runCurrent() + + assertEquals("Suggestions are locked", viewModel.uiState.value.error) + viewModel.clearError() + assertEquals(null, viewModel.uiState.value.error) + } + + private class FailingLobbyApi : WatchTogetherApi { + private val roomResponse = + ApiResult.Success(RoomResponse(RoomSnapshot(roomId = "room-1"), "room-token")) + + override suspend fun createRoom(request: CreateRoomRequest, scope: AuthScopeSnapshot) = roomResponse + override suspend fun joinRoom(request: JoinRoomRequest, scope: AuthScopeSnapshot) = roomResponse + override suspend fun getRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = roomResponse + override suspend fun setSelection( + roomId: String, + roomToken: String, + request: SetSelectionRequest, + scope: AuthScopeSnapshot, + ) = roomResponse + + override suspend fun updatePolicy( + roomId: String, + roomToken: String, + request: UpdatePolicyRequest, + scope: AuthScopeSnapshot, + ) = roomResponse + + override suspend fun closeRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = + ApiResult.Success(Unit) + + override suspend fun listSuggestions( + roomId: String, + roomToken: String, + scope: AuthScopeSnapshot, + ) = ApiResult.Success(SuggestionsResponse()) + + override suspend fun addSuggestion( + roomId: String, + roomToken: String, + request: AddSuggestionRequest, + scope: AuthScopeSnapshot, + ): ApiResult = + ApiResult.Error(409, "suggestions_locked", "Suggestions are locked") + + override suspend fun deleteSuggestion( + roomId: String, + roomToken: String, + suggestionId: String, + scope: AuthScopeSnapshot, + ) = ApiResult.Success(SuggestionsResponse()) + + override suspend fun vote( + roomId: String, + roomToken: String, + suggestionId: String, + scope: AuthScopeSnapshot, + ): ApiResult = + ApiResult.Error(409, "voting_disabled", "Voting is disabled") + + override suspend fun unvote( + roomId: String, + roomToken: String, + suggestionId: String, + scope: AuthScopeSnapshot, + ) = ApiResult.Success(SuggestionsResponse()) + + override suspend fun promoteSuggestion( + roomId: String, + roomToken: String, + request: PromoteSuggestionRequest, + scope: AuthScopeSnapshot, + ): ApiResult = + ApiResult.Error(403, "host_required", "Only the host can promote") + } + + private companion object { + val AUTH_SCOPE = AuthScopeSnapshot( + serverId = "server-1", + profileId = "profile-1", + serverUrl = "https://example.test", + profileToken = "profile-token", + identityGeneration = 1L, + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntrySourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntrySourceTest.kt new file mode 100644 index 000000000..733467d61 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntrySourceTest.kt @@ -0,0 +1,77 @@ +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvWatchTogetherMenuEntrySourceTest { + private val shell = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt", + ).readText() + private val dialog = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntryDialog.kt", + ).readText() + + @Test + fun profileRowIsImmediatelyAfterRequestsAndBeforeSettings() { + val profile = shell.substringAfter("private fun TvProfileDropdown(") + val watch = profile.indexOf("label = \"Watch Together\"") + val requests = profile.indexOf("label = \"Requests\"") + val settings = profile.indexOf("label = \"Settings\"") + val watchRow = profile.lastIndexOf("ProfileDropdownRow(", startIndex = watch) + assertTrue(watch >= 0) + assertTrue(requests >= 0) + assertTrue(settings >= 0) + assertTrue(watchRow >= 0) + assertTrue(requests < watch) + assertTrue(watch < settings) + assertFalse( + profile.substring( + startIndex = requests + "label = \"Requests\"".length, + endIndex = watchRow, + ).contains("ProfileDropdownRow("), + ) + assertTrue(shell.contains("CLIENT_WATCH_TOGETHER_SURFACE_ENABLED")) + } + + @Test + fun popupOwnsFocusAndBackRestoresProfileFocus() { + assertTrue(dialog.contains("PopupProperties(")) + assertTrue(dialog.contains("focusable = true")) + assertTrue(dialog.contains("rememberTvDialogInitialFocus(initialFocus)")) + assertTrue(shell.contains("focusState.closeProfileMenuForContent()")) + assertTrue(shell.contains("focusState.dismissProfileMenu()")) + } + + @Test + fun menuSurfaceUsesExistingControllerAndNoCredentials() { + assertTrue(shell.contains("watchTogetherViewModel.createEmptyVoteRoom()")) + assertTrue(shell.contains("watchTogetherViewModel.resumeCurrentRoom()")) + assertTrue(shell.contains("TvJoinCodeDialog(")) + listOf("Resume current room", "Host a room", "Join by code").forEach { label -> + assertTrue(dialog.contains(label)) + } + assertFalse(dialog.contains("WatchTogetherApi")) + assertFalse(dialog.contains("room_token")) + assertFalse(dialog.contains("roomAccessToken")) + assertFalse(dialog.contains("Authorization")) + assertFalse(dialog.contains("CleartextOriginConsent")) + assertFalse(dialog.contains("HttpClient")) + assertTrue(shell.contains("error = watchTogetherState.error")) + assertTrue(dialog.contains("error?.let")) + } + + @Test + fun initialActionPrefersResumeOnlyWhenAvailable() { + assertEquals( + TvWatchTogetherMenuInitialAction.Resume, + tvWatchTogetherMenuInitialAction(canResume = true), + ) + assertEquals( + TvWatchTogetherMenuInitialAction.Host, + tvWatchTogetherMenuInitialAction(canResume = false), + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherSurfaceSourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherSurfaceSourceTest.kt index d44653ce2..1e3df2999 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherSurfaceSourceTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherSurfaceSourceTest.kt @@ -37,7 +37,7 @@ class TvWatchTogetherSurfaceSourceTest { fun aResolvedRoomReachesTheNavigationCallback() { assertTrue(itemDetailScreen.contains("onWatchTogether(room)")) assertTrue(itemDetailScreen.contains("watchTogetherViewModel.consumeResult()")) - assertTrue(appNavigation.contains("TvRoute.WatchTogetherLobby(roomId = snapshot.roomId).route")) + assertTrue(appNavigation.contains("tvWatchTogetherDestination(snapshot)")) } @Test diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModelTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModelTest.kt new file mode 100644 index 000000000..5b125d3d9 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModelTest.kt @@ -0,0 +1,136 @@ +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest +import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomResponse +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class TvWatchTogetherViewModelTest { + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun emptyHostCreatesVoteRoomWithoutSelection() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = TvWatchTogetherViewModel(gateway) + + viewModel.createEmptyVoteRoom() + + assertEquals( + listOf(CreateRoomRequest(RoomSelectionMode.Vote.wire)), + gateway.createRequests, + ) + assertEquals(emptyList(), gateway.selectionRequests) + assertEquals("room-1", viewModel.uiState.value.result?.roomId) + } + + @Test + fun resumeUsesCurrentRoomWithoutNetworkCalls() = runTest(dispatcher) { + val room = RoomSnapshot(roomId = "room-1", phase = RoomPhase.Lobby) + val gateway = FakeGateway(room) + val viewModel = TvWatchTogetherViewModel(gateway) + + viewModel.resumeCurrentRoom() + + assertEquals(room, viewModel.uiState.value.result) + assertEquals(emptyList(), gateway.createRequests) + assertEquals(emptyList(), gateway.joinRequests) + } + + @Test + fun joinCodeNormalizesAndKeepsExistingErrorCopy() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = TvWatchTogetherViewModel(gateway) + + viewModel.joinRoom(" abcd1234 ") + assertEquals(listOf(JoinRoomRequest(code = "ABCD1234")), gateway.joinRequests) + + viewModel.consumeResult() + gateway.joinResult = ApiResult.NetworkError(IllegalStateException("offline")) + viewModel.joinRoom("efgh5678") + assertEquals("Network error. Check your connection.", viewModel.uiState.value.error) + } + + @Test + fun titleDetailHostStillSetsSelection() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = TvWatchTogetherViewModel(gateway) + + viewModel.createRoom(contentId = "movie-1", fileId = 7) + + assertEquals( + listOf(SetSelectionRequest(contentId = "movie-1", fileId = 7)), + gateway.selectionRequests, + ) + } + + private class FakeGateway( + room: RoomSnapshot? = null, + ) : WatchTogetherEntryGateway { + override val roomSnapshot = MutableStateFlow(room) + val createRequests = mutableListOf() + val joinRequests = mutableListOf() + val selectionRequests = mutableListOf() + var joinResult: ApiResult? = null + var nextRoom = RoomSnapshot( + roomId = "room-1", + phase = RoomPhase.Lobby, + selectionMode = RoomSelectionMode.Vote, + ) + + override suspend fun createRoom( + request: CreateRoomRequest, + ): ApiResult { + createRequests += request + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } + + override suspend fun joinRoom( + request: JoinRoomRequest, + ): ApiResult { + joinRequests += request + joinResult?.let { return it } + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } + + override suspend fun setSelection( + request: SetSelectionRequest, + ): ApiResult { + selectionRequests += request + nextRoom = nextRoom.copy( + selectedContentId = request.contentId, + selectedFileId = request.fileId, + ) + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvContentUpNavigationTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvContentUpNavigationTest.kt new file mode 100644 index 000000000..d1053f763 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvContentUpNavigationTest.kt @@ -0,0 +1,17 @@ +package org.prairieserver.prairie.tv.ui.shell + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvContentUpNavigationTest { + @Test + fun heldUpAfterContentCannotMoveStaysInContent() { + assertFalse(shouldRequestMenuAfterContentUp(movedWithinContent = false, isRepeat = true)) + } + + @Test + fun freshUpAfterContentCannotMoveEntersMenu() { + assertTrue(shouldRequestMenuAfterContentUp(movedWithinContent = false, isRepeat = false)) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusStateTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusStateTest.kt index e5769a1bb..59914b986 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusStateTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusStateTest.kt @@ -98,6 +98,43 @@ class TvShellFocusStateTest { ) } + @Test + fun backFromRootContentRetainsTheActiveRootAsItsMenuTarget() { + val state = TvShellFocusState() + + assertEquals( + TvShellBackAction.MoveFocusToMenu, + state.onBack( + onTabRoot = true, + menuFocusTarget = moviesPanel, + ), + ) + + assertEquals(moviesPanel, state.menuFocusTarget) + } + + @Test + fun contentUpOnSecondaryRoutesDoesNotFallbackToHomeFocus() { + val state = TvShellFocusState() + val before = state.menuFocusRequest + + state.requestMenuFocusIfAvailable(target = null) + + assertEquals(before, state.menuFocusRequest) + assertNull(state.menuFocusTarget) + } + + @Test + fun contentUpOnSearchMayUseTheSearchOwnedNullTarget() { + val state = TvShellFocusState() + val before = state.menuFocusRequest + + state.requestMenuFocusIfAvailable(target = null, allowNullTarget = true) + + assertEquals(before + 1, state.menuFocusRequest) + assertNull(state.menuFocusTarget) + } + @Test fun backOnSecondaryScreensStillDelegatesToNav() { assertEquals( @@ -190,6 +227,20 @@ class TvShellFocusStateTest { assertEquals(before, s.profileFocusRequest) } + @Test + fun closingMenuForPopupThenDismissingPopupRefocusesAvatar() { + val state = TvShellFocusState() + state.previewProfileMenu() + state.enterProfileMenu() + val before = state.profileFocusRequest + + state.closeProfileMenuForContent() + assertEquals(before, state.profileFocusRequest) + + state.dismissProfileMenu() + assertEquals(before + 1, state.profileFocusRequest) + } + @Test fun profileDwellPreviewsWithoutStealingFocusAndDownEnters() { val s = TvShellFocusState() diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequestTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequestTest.kt new file mode 100644 index 000000000..e19345b18 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequestTest.kt @@ -0,0 +1,117 @@ +package org.prairieserver.prairie.tv.ui.shell + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvTopMenuFocusRequestTest { + @Test + fun focusIsRequestedOnlyAfterTheTargetHasHadAFrameToCompose() = runTest { + val events = mutableListOf() + + requestTopMenuFocusUntilApplied( + awaitFrame = { events += "frame" }, + requestFocus = { events += "focus"; true }, + ) + + assertEquals(listOf("frame", "focus"), events) + } + + @Test + fun aTargetThatIsNotAttachedYetIsRetriedOnTheNextFrame() = runTest { + val events = mutableListOf() + var attempts = 0 + + requestTopMenuFocusUntilApplied( + awaitFrame = { events += "frame" }, + requestFocus = { + events += "focus" + attempts += 1 + attempts == 2 + }, + ) + + assertEquals(listOf("frame", "focus", "frame", "focus"), events) + } + + @Test + fun retryStopsWhenItsTargetIsNoLongerCurrent() = runTest { + val events = mutableListOf() + var targetIsCurrent = true + + requestTopMenuFocusUntilApplied( + awaitFrame = { events += "frame" }, + isTargetCurrent = { targetIsCurrent }, + requestFocus = { + events += "focus" + targetIsCurrent = false + false + }, + ) + + assertEquals(listOf("frame", "focus"), events) + } + + @Test + fun retryStopsAfterSixFramesWhenTheTargetNeverAttaches() = runTest { + var frames = 0 + var attempts = 0 + + requestTopMenuFocusUntilApplied( + awaitFrame = { frames += 1 }, + requestFocus = { + attempts += 1 + attempts == 7 + }, + ) + + assertEquals(6, frames) + assertEquals(6, attempts) + } + + @Test + fun libraryTargetIsNotAvailableAfterItsDestinationDisappears() { + val movies = TvRootDestination.LibraryType(TvLibraryTabType.Movies) + + assertEquals( + false, + isTopMenuFocusTargetAvailable( + target = TvTopMenuPanel.Root(movies), + destinations = listOf(TvRootDestination.Home, TvRootDestination.Calendar), + ), + ) + } + + @Test + fun focusRequestRemainsPendingUntilItsLibraryDestinationAppears() = runTest { + val movies = TvRootDestination.LibraryType(TvLibraryTabType.Movies) + val identity = 1 to TvTopMenuPanel.Root(movies) + val initialHandled = 0 to null + var focusAttempts = 0 + + val handledWhileAbsent = handleTopMenuFocusRequestIfAvailable( + requestIdentity = identity, + lastHandledRequest = initialHandled, + isFocusSuppressed = false, + isTargetAvailable = false, + requestFocus = { + focusAttempts += 1 + true + }, + ) + val handledAfterAppearing = handleTopMenuFocusRequestIfAvailable( + requestIdentity = identity, + lastHandledRequest = handledWhileAbsent, + isFocusSuppressed = false, + isTargetAvailable = true, + requestFocus = { + focusAttempts += 1 + true + }, + ) + + assertEquals(initialHandled, handledWhileAbsent) + assertEquals(identity, handledAfterAppearing) + assertEquals(1, focusAttempts) + } +} diff --git a/docs/notes/2026-07-27-pr108-slice-b-traceability.md b/docs/notes/2026-07-27-pr108-slice-b-traceability.md index b63fd0675..038f94dc9 100644 --- a/docs/notes/2026-07-27-pr108-slice-b-traceability.md +++ b/docs/notes/2026-07-27-pr108-slice-b-traceability.md @@ -19,7 +19,7 @@ state needed by that slice. | `65c4b316` | Credential-generation scope prerequisite | Relevant token/scope net state is carried by `aeead9d9` | | `1fecf9b1` | Central HTTP origin model | Folded with its follow-up into `38fbf837` | | `cbf398fa` | Reject ambiguous authorities and normalize safe HTTP origins | Folded with `1fecf9b1` into `38fbf837` | -| `5d5f0562` | Never attach or refresh Silo credentials off-origin | Ported as `aeead9d9` | +| `5d5f0562` | Never attach or refresh Prairie credentials off-origin | Ported as `aeead9d9` | | `e18d092e` | Reject stale persistent credential generations | Ported in `aeead9d9`, with startup-snapshot correction in `15278f95` | | `c07d9f9f` | Require explicit cleartext-origin consent | Ported as `dd1e0992`, with client/media/startup/pairing enforcement in `15278f95` | | `85890c6d` | Parsed EPUB allowlist sanitizer | Cherry-picked with `-x` as `60d20931` | diff --git a/docs/plans/uiux-overhaul-remaining-phases.md b/docs/plans/uiux-overhaul-remaining-phases.md index b4e75729c..38144725f 100644 --- a/docs/plans/uiux-overhaul-remaining-phases.md +++ b/docs/plans/uiux-overhaul-remaining-phases.md @@ -32,7 +32,7 @@ Kotlin Multiplatform. Modules: Stack: Kotlin 2.1.20, Compose Multiplatform 1.7.3, Material3, Compose-for-TV 1.0.1, Coil3 3.1.0, Media3/ExoPlayer 1.10.0 + libmpv, Koin DI, navigation-compose 2.9.0, Room 2.8.4, DataStore, WorkManager. minSdk 24, targetSdk 35, compileSdk 36. -Package root `org.prairieserver.prairie` (dirs say "silo"; product is "Prairie"). +Package root `org.prairieserver.prairie` (dirs say "prairie"; product is "Prairie"). iOS-parity is a deliberate design driver — many files say "Mirrors iosApp …". --- @@ -330,7 +330,7 @@ no resource shrinking. No Baseline Profile, no `profileinstaller`, no signingCon ## 6. Quick-start checklist for the new session 1. `git checkout uiux-fluidity-overhaul` and read recent commits (`git log --oneline -10`). -2. Re-read this doc + the memory note `silo-uiux-overhaul-plan` and `silo-build-on-windows`. +2. Re-read this doc + the memory note `prairie-uiux-overhaul-plan` and `prairie-build-on-windows`. 3. Confirm the branch tip still compiles green using the §1 procedure (grep the output!). 4. Pick a phase. 6 is non-gated; 7 and 8 require an owner checkpoint before merge. 5. Work in per-phase commits; keep source-guard tests green; verify by grepping diff --git a/docs/reviews/2026-07-27-android-tv-navigation-remediation-executive-summary.md b/docs/reviews/2026-07-27-android-tv-navigation-remediation-executive-summary.md new file mode 100644 index 000000000..21b2f0761 --- /dev/null +++ b/docs/reviews/2026-07-27-android-tv-navigation-remediation-executive-summary.md @@ -0,0 +1,91 @@ +# Android TV Navigation Remediation — Executive Summary + +## Decision + +Ship the cache-first Android TV navigation changes for external testing. The +change addresses the cold-start navigation slowdown and the broken `For You` +focus transition reported in issues #121 and #122 without changing server +contracts, playback behavior, or subtitle handling. + +## Customer impact + +On a large production library, the first trip across Home rows caused bursts of +detail requests and garbage collection while focus was moving. Returning over +the same rows was substantially faster because those details were then cached. +The `For You` filter-to-card transition also lacked an explicit focus bridge, +which could leave Down navigation stuck in the filter controls. + +External testing of the first build found two smaller consistency issues: +holding Up while returning through several Home rows could carry focus straight +into the top menu, and Watchlist/Favorites selected from the For You dropdown +opened visually different standalone pages from the equivalent in-page pills. + +## Verified causes + +- Home inline sections could be hydrated more than once. +- Marquee enrichment and Skyline prefetch treated a cold detail cache as an + invitation to fan out requests during focus movement. +- Skyline work could outlive or race the focus state that requested it. +- `For You` did not explicitly transfer focus between its filters and first + result card. +- The Home row fallback accepted overlapping off-screen relocation requests and + treated a repeated Up event at row zero like a deliberate fresh menu-entry + press. +- The For You dropdown routed saved lists to standalone destinations while the + in-page pills used inline grids. + +Archived PR #108 contained earlier versions of the relevant performance ideas +in commits `49a70045` and `65c4b316`, but those commits were not ancestors of +current `main`. This branch forward-ports the behavior against the current +architecture with focused tests and bounded concurrency. + +## Changes + +- Hydrate shared inline Home sections once. +- Make marquee detail enrichment cache-first. +- Start Skyline prefetch only after focus settles and cancel obsolete work. +- Add explicit, tested `For You` filter/card focus transitions. +- Serialize off-screen Up relocation, stop held/repeated Up on Home's first + content row, and require a fresh Up press to enter the top menu. +- Route For You dropdown Watchlist/Favorites choices through the same inline + presentation as their in-page pills; profile-menu entries remain standalone. +- Add regression coverage for cache misses, request bounds, stale completion, + cancellation, repeat-key boundaries, saved-list requests, and focus routing. + +## Evidence + +Production-backed emulator profiling before the change showed: + +| Scenario | Janky frames | p95 | p99 | HTTP requests | +| --- | ---: | ---: | ---: | ---: | +| Horizontal cold | 18.34% | 53 ms | 700 ms | 45 | +| Horizontal warm | 5.09% | — | — | 0 | +| Second cold | 15.13% | 42 ms | 1000 ms | 46 | +| Vertical cold | 27.34% | 250 ms | 1000 ms | 47 | +| Vertical warm | 12.97% | 34 ms | 109 ms | 4 | + +After the change, a combined cold-navigation run recorded 769 frames, 114 +janky frames (14.82%), p50 10 ms, p90 24 ms, p95 46 ms, and p99 300 ms. The +captured run contained only five combined HTTP-completion/GC log matches. + +Supply-chain policy checks, shared and TV-focused unit tests, Android shared +tests, and the minified TV release assembly passed. A final independent review +approved the branch after two correction rounds. + +The device session ended before a complete Down/card/Up `For You` focus smoke +could be recorded. The focus bridge, repeat-key boundary, and saved-list +selection state are covered by behavioral unit tests, so external testing +should explicitly include those remote-control paths. + +## Risk and rollback + +Risk is concentrated in prefetch timing: slower networks may display metadata +slightly later because uncached enrichment no longer competes with active +focus navigation. Content remains available through the normal detail path. +The commits are separated by concern, so the focus bridge, repeat-key boundary, +saved-list routing, or either prefetch policy can be reverted independently if +external testing finds a regression. + +The test APKs are debug-signed release builds and cannot replace an installed +production-signed build without a matching signer. They are intended for a +compatible tester installation only. diff --git a/docs/superpowers/notes/architecture-debate.md b/docs/superpowers/notes/architecture-debate.md index ba64263a9..cefc877cd 100644 --- a/docs/superpowers/notes/architecture-debate.md +++ b/docs/superpowers/notes/architecture-debate.md @@ -707,7 +707,7 @@ Claude proposed a narrower re-scope after the Explore map showed the plan's draf **Projection key (Codex won):** add a **separate `content_item_state` table** PK `(serverId, profileId, contentId)` for watched/rating/favorite — these mutations carry no fileId and can fire before any file row exists. `UserItemStateEntity` stays file-level (position/track/CFI); drop watched/ratingValue/favorite from it. Matches the outbox's `targetFileId: Int?` (null for content ops). **Coalesce key now serverId-scoped**: `serverId|profileId|contentId|kind`. -**Schema:** amend v1 in place (no migration) — the DB is committed but unreleased and not yet wired into DI, so no device has `silo.db`. +**Schema:** amend v1 in place (no migration) — the DB is committed but unreleased and not yet wired into DI, so no device has `prairie.db`. **DI:** commonMain `single { PersonalDataRepository(get(), getOrNull() ?: NoOpUserItemStatePort) }`; `androidModule`+`androidTvModule` bind `PrairieDatabase` and `single { RoomUserItemStateRepository(...) }` after `sharedModules()` (mirrors TokenManager override at `AndroidModule.kt:94`). diff --git a/docs/superpowers/plans/2026-05-23-c-device-login.md b/docs/superpowers/plans/2026-05-23-c-device-login.md index 939553024..0d50a1a8c 100644 --- a/docs/superpowers/plans/2026-05-23-c-device-login.md +++ b/docs/superpowers/plans/2026-05-23-c-device-login.md @@ -441,8 +441,8 @@ class DeviceLoginRepositoryTest { deviceCode = deviceCode, userCode = "ABCD-1234", matchCode = "M1", - verificationUri = "https://silo.example/device", - verificationUriComplete = "https://silo.example/device?token=t1", + verificationUri = "https://prairie.example/device", + verificationUriComplete = "https://prairie.example/device?token=t1", expiresAt = "2099-01-01T00:00:00Z", expiresIn = 600, interval = 1, // small for tests diff --git a/docs/superpowers/plans/2026-06-09-android-media-surfaces.md b/docs/superpowers/plans/2026-06-09-android-media-surfaces.md index 0ab9f2276..b4083b959 100644 --- a/docs/superpowers/plans/2026-06-09-android-media-surfaces.md +++ b/docs/superpowers/plans/2026-06-09-android-media-surfaces.md @@ -1561,9 +1561,9 @@ git status --short --branch Expected: branch `feature/android-parity-and-media-surfaces` with a clean worktree after all task commits. -- [ ] **Step 5: Manual smoke test against `root@silo-new` server** +- [ ] **Step 5: Manual smoke test against `root@prairie-new` server** -Use a debug build pointed at the Prairie server that matches `root@silo-new:/opt/prairie-server`. +Use a debug build pointed at the Prairie server that matches `root@prairie-new:/opt/prairie-server`. Verify: diff --git a/docs/superpowers/plans/2026-06-09-mobile-device-login-parity.md b/docs/superpowers/plans/2026-06-09-mobile-device-login-parity.md index 9ab038098..b9061bc50 100644 --- a/docs/superpowers/plans/2026-06-09-mobile-device-login-parity.md +++ b/docs/superpowers/plans/2026-06-09-mobile-device-login-parity.md @@ -21,8 +21,8 @@ Assert these inputs resolve to `Route.PairDevice(...).route`: - `prairie://device?token=t1` - `continuum://device?code=ABCD-1234` -- `https://silo.example/device?token=t1` -- `https://silo.example/auth/device?code=ABCD` +- `https://prairie.example/device?token=t1` +- `https://prairie.example/auth/device?code=ABCD` Assert unrelated URLs return null. diff --git a/docs/superpowers/plans/2026-06-12-admin-core.md b/docs/superpowers/plans/2026-06-12-admin-core.md index 0f66621e4..23ebe4c0f 100644 --- a/docs/superpowers/plans/2026-06-12-admin-core.md +++ b/docs/superpowers/plans/2026-06-12-admin-core.md @@ -21,9 +21,9 @@ The convention is `./gradlew :shared:testDebugUnitTest --tests "..."` for common ### Task S1: Gating — `Profile.isPrimary` + shared `isActingAdmin` **Files:** -- Modify: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/profile/ProfileModels.kt` -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/auth/AdminPermissions.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/auth/AdminPermissionsTest.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/profile/ProfileModels.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/auth/AdminPermissions.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/auth/AdminPermissionsTest.kt` Evidence (server gate, design §"Admin gating"): acting admin = `user.role == "admin"` AND active `profile.is_primary == true`. `Profile` currently has **no** `is_primary` field (verified in ProfileModels.kt) so it must be added. The UI computes this from `AuthRepository`/`AuthApi.getMe()` (returns `User` with `role`) and `ProfileRepository.getActiveProfile(): Profile?`. Placing `isActingAdmin` in `model/auth` keeps it package-adjacent to `User` while accepting a `Profile` param. @@ -91,7 +91,7 @@ class AdminPermissionsTest { - [ ] **Step 2: Run test to verify it fails** (command + expected failure) ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.auth.AdminPermissionsTest" +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.auth.AdminPermissionsTest" ``` Expected: compilation failure — `Profile` has no `isPrimary` member and `isActingAdmin` is unresolved (`unresolved reference: isPrimary`, `unresolved reference: isActingAdmin`). @@ -146,7 +146,7 @@ fun isActingAdmin(user: User?, profile: Profile?): Boolean = - [ ] **Step 4: Run tests** (command) ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.auth.AdminPermissionsTest" --tests "com.continuum.app.model.profile.*" +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.auth.AdminPermissionsTest" --tests "com.continuum.app.model.profile.*" ``` - [ ] **Step 5: Commit** @@ -165,8 +165,8 @@ Co-Authored-By: Claude Fable 5 " ### Task S2: `model/admin/AdminModels.kt` — admin DTOs + serialization tests **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/admin/AdminModels.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/admin/AdminModelsSerializationTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/admin/AdminModels.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/admin/AdminModelsSerializationTest.kt` Evidence (Go structs, exact `json:"..."` tags): - `AdminStats` + `WatchProviderActivity` from `admin_stats.go` (quoted above): all snake_case; `total_storage_bytes int64`; `WatchProviderActivity` has `trakt_connected_profiles`, `trakt_enabled_profiles`, `trakt_export_enabled`, `trakt_scrobble_enabled`, `last_sync_completed_at *time.Time (omitempty)`, `sync_runs_24h`, `sync_errors_24h`, `imported_watched_24h`, `imported_progress_24h`, `exported_watched_24h`, `pending_exports`, `failed_exports`, `open_scrobbles`, `scrobbles_24h`. @@ -491,7 +491,7 @@ class AdminModelsSerializationTest { - [ ] **Step 2: Run test to verify it fails** (command + expected failure) ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.admin.AdminModelsSerializationTest" +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.admin.AdminModelsSerializationTest" ``` Expected: compilation failure — `unresolved reference: AdminStats / AdminUser / CreateUserRequest / AdminSession / AdminLogPage / AdminAuditPage / ScanRequest / ScanResponse / ScanCancelResponse` (the model file does not yet exist). @@ -789,7 +789,7 @@ data class ScanCancelResponse( - [ ] **Step 4: Run tests** (command) ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.admin.AdminModelsSerializationTest" +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.admin.AdminModelsSerializationTest" ``` - [ ] **Step 5: Commit** @@ -807,9 +807,9 @@ Co-Authored-By: Claude Fable 5 " ### Task S3: `network/api/AdminApi.kt` (interface + Default) + NetworkModule + MockEngine tests **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/api/AdminApi.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/NetworkModule.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/api/AdminApiTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/api/AdminApi.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/NetworkModule.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/api/AdminApiTest.kt` Evidence (routes, all under `/api/v1`, design §"Server contracts"; verified handlers): - `GET /admin/stats` with optional `?refresh=true` (admin_stats.go provider; refresh forces a cache bypass server-side). @@ -1124,7 +1124,7 @@ class AdminApiTest { - [ ] **Step 2: Run test to verify it fails** (command + expected failure) ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.network.api.AdminApiTest" +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.network.api.AdminApiTest" ``` Expected: compilation failure — `unresolved reference: AdminApi / DefaultAdminApi` (the api file does not yet exist). @@ -1377,7 +1377,7 @@ Register in NetworkModule (add the line after the NotificationsApi binding): - [ ] **Step 4: Run tests** (command) ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.network.api.AdminApiTest" +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.network.api.AdminApiTest" ``` - [ ] **Step 5: Commit** @@ -1396,9 +1396,9 @@ Co-Authored-By: Claude Fable 5 " ### Task S4: `repository/AdminRepository.kt` — pass-throughs + RepositoryModule + repository test **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/repository/AdminRepository.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/RepositoryModule.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/repository/AdminRepositoryTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/repository/AdminRepository.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/RepositoryModule.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/repository/AdminRepositoryTest.kt` Evidence: existing repos are thin `ApiResult` pass-throughs around an interface-backed API (e.g. `SubtitlesRepository(get())` in RepositoryModule; `SubtitlesApi`/`NotificationsApi` are interfaces faked in tests). `AdminApi` is an interface (Task 3), so the repository is constructed `AdminRepository(get())` and the test fakes `AdminApi`. The scan endpoints live under `/libraries` but are reached via `AdminApi` (documented there); the repository exposes them as `triggerScan`/`cancelScan` so the admin "Scans" sub-screen has a single dependency — KDoc records the `/libraries` placement. @@ -1601,7 +1601,7 @@ class AdminRepositoryTest { - [ ] **Step 2: Run test to verify it fails** (command + expected failure) ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.repository.AdminRepositoryTest" +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.repository.AdminRepositoryTest" ``` Expected: compilation failure — `unresolved reference: AdminRepository` (the repository does not yet exist). @@ -1728,7 +1728,7 @@ import com.continuum.app.repository.AdminRepository - [ ] **Step 4: Run tests** (command) ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.repository.AdminRepositoryTest" +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.repository.AdminRepositoryTest" ``` - [ ] **Step 5: Commit** diff --git a/docs/superpowers/plans/2026-06-12-android-client-review-fixes.md b/docs/superpowers/plans/2026-06-12-android-client-review-fixes.md index 3b61a46db..1018a9a2c 100644 --- a/docs/superpowers/plans/2026-06-12-android-client-review-fixes.md +++ b/docs/superpowers/plans/2026-06-12-android-client-review-fixes.md @@ -2234,11 +2234,11 @@ Co-Authored-By: Claude Fable 5 " ### Task D1: Fix WatchNextSyncWorker instantiation (TvWorkerFactory + manifest opt-out + explicit WorkManager init) **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/TvWorkerFactory.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ContinuumTvApplication.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/AndroidManifest.xml` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/WatchNextSyncWorker.kt` (doc comment only) +- Create: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/TvWorkerFactory.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ContinuumTvApplication.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/AndroidManifest.xml` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/WatchNextSyncWorker.kt` (doc comment only) - Test: none (manual verification — see Step 1) - [ ] **Step 1: Write the failing test** @@ -2273,7 +2273,7 @@ Run the *before* manual check above on the current branch build (`./gradlew :and - [ ] **Step 3: Implementation** -**3a. Create `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/TvWorkerFactory.kt`:** +**3a. Create `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/TvWorkerFactory.kt`:** ```kotlin package com.continuum.app.tv.watchnext @@ -2333,7 +2333,7 @@ class TvWorkerFactory : WorkerFactory() { } ``` -**3b. Replace the entire contents of `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ContinuumTvApplication.kt`:** +**3b. Replace the entire contents of `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ContinuumTvApplication.kt`:** ```kotlin package com.continuum.app.tv @@ -2388,7 +2388,7 @@ class ContinuumTvApplication : Application(), Configuration.Provider { } ``` -**3c. In `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/AndroidManifest.xml`** — add the `tools` namespace to the root element: +**3c. In `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/AndroidManifest.xml`** — add the `tools` namespace to the root element: ```xml ` closing tag of `ContinuumPlaybackSe ``` -**3d. In `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt`** — delete these two imports: +**3d. In `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt`** — delete these two imports: ```kotlin import com.continuum.app.tv.watchnext.WatchNextSyncWorker @@ -2445,7 +2445,7 @@ with: single { WatchNextSeeder(androidContext(), get()) } ``` -**3e. In `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/WatchNextSyncWorker.kt`** — replace the stale doc-comment lines: +**3e. In `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/WatchNextSyncWorker.kt`** — replace the stale doc-comment lines: ```kotlin * Constructed by Koin's [org.koin.androidx.workmanager.factory.KoinWorkerFactory] @@ -2472,7 +2472,7 @@ Then re-run the *after* manual check from Step 1 on a device/emulator. - [ ] **Step 5: Commit** ```bash -cd /Users/dev/projects/silo/prairie-android && git add androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/TvWorkerFactory.kt androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ContinuumTvApplication.kt androidTvApp/src/androidMain/AndroidManifest.xml androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/WatchNextSyncWorker.kt && git commit -m "$(cat <<'EOF' +cd /Users/dev/projects/prairie/prairie-android && git add androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/TvWorkerFactory.kt androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ContinuumTvApplication.kt androidTvApp/src/androidMain/AndroidManifest.xml androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/WatchNextSyncWorker.kt && git commit -m "$(cat <<'EOF' Fix WatchNextSyncWorker instantiation on TV Mirror the phone app's WorkManager recipe: hand-rolled TvWorkerFactory, @@ -2490,12 +2490,12 @@ EOF ### Task D2: Reinstate one-shot legacy tv_prefs migration (playback settings + library selection) **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigration.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/settings/TvSettingsViewModel.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/libraries/TvLibrariesViewModel.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/build.gradle.kts` (add `kotlinx-coroutines-test` to androidUnitTest deps) -- Test: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidUnitTest/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigrationTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigration.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/settings/TvSettingsViewModel.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/libraries/TvLibrariesViewModel.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/build.gradle.kts` (add `kotlinx-coroutines-test` to androidUnitTest deps) +- Test: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidUnitTest/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigrationTest.kt` Design notes (decided after reading main's `TvPreferences.kt` / `TvSettingsViewModel.kt`): - Exact legacy keys in the `tv_prefs` DataStore: `playback_quality` (string wire value), `subtitle_size` (string label `Small`/`Medium`/`Large`), `auto_play_next` (bool, default true), `auto_skip_intro` (bool, default false), `auto_skip_credits` (bool, default false), `libraries_selected_library_id` (int). @@ -2504,7 +2504,7 @@ Design notes (decided after reading main's `TvPreferences.kt` / `TvSettingsViewM - [ ] **Step 1: Write the failing test** -First add the missing coroutines-test dependency. In `/Users/dev/projects/silo/prairie-android/androidTvApp/build.gradle.kts`, change: +First add the missing coroutines-test dependency. In `/Users/dev/projects/prairie/prairie-android/androidTvApp/build.gradle.kts`, change: ```kotlin androidUnitTest.dependencies { @@ -2523,7 +2523,7 @@ to: } ``` -Create `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidUnitTest/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigrationTest.kt`: +Create `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidUnitTest/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigrationTest.kt`: ```kotlin package com.continuum.app.tv.data.preferences @@ -2918,13 +2918,13 @@ private class FakeTokenManager( - [ ] **Step 2: Run test to verify it fails** ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :androidTvApp:testDebugUnitTest --tests "com.continuum.app.tv.data.preferences.LegacyTvPrefsMigrationTest" +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :androidTvApp:testDebugUnitTest --tests "com.continuum.app.tv.data.preferences.LegacyTvPrefsMigrationTest" ``` Expected failure: compilation error in `compileDebugUnitTestKotlinAndroid` — `e: ... LegacyTvPrefsMigrationTest.kt: ... Unresolved reference 'LegacyTvPrefsMigration'` (the class does not exist yet). - [ ] **Step 3: Implementation** -**3a. Create `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigration.kt`:** +**3a. Create `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigration.kt`:** ```kotlin package com.continuum.app.tv.data.preferences @@ -3110,7 +3110,7 @@ class LegacyTvPrefsMigration( } ``` -**3b. In `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/settings/TvSettingsViewModel.kt`** — add the import: +**3b. In `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/settings/TvSettingsViewModel.kt`** — add the import: ```kotlin import com.continuum.app.tv.data.preferences.LegacyTvPrefsMigration @@ -3188,7 +3188,7 @@ to: (rest of the function unchanged). -**3c. In `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/libraries/TvLibrariesViewModel.kt`** — add the import: +**3c. In `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/libraries/TvLibrariesViewModel.kt`** — add the import: ```kotlin import com.continuum.app.tv.data.preferences.LegacyTvPrefsMigration @@ -3236,7 +3236,7 @@ to: val storedLibraryId = librarySelectionStore.getSelectedLibraryId() ``` -**3d. In `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt`** — add imports: +**3d. In `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt`** — add imports: ```kotlin import com.continuum.app.network.ApiResult @@ -3316,14 +3316,14 @@ to: - [ ] **Step 4: Run tests** ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :androidTvApp:testDebugUnitTest --tests "com.continuum.app.tv.data.preferences.LegacyTvPrefsMigrationTest" && ./gradlew :androidTvApp:testDebugUnitTest :androidTvApp:assembleDebug +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :androidTvApp:testDebugUnitTest --tests "com.continuum.app.tv.data.preferences.LegacyTvPrefsMigrationTest" && ./gradlew :androidTvApp:testDebugUnitTest :androidTvApp:assembleDebug ``` Expected: all 8 new tests pass, the rest of the TV suite stays green, and the app module compiles (verifies the DI/ViewModel wiring). - [ ] **Step 5: Commit** ```bash -cd /Users/dev/projects/silo/prairie-android && git add androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigration.kt androidTvApp/src/androidUnitTest/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigrationTest.kt androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/settings/TvSettingsViewModel.kt androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/libraries/TvLibrariesViewModel.kt androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt androidTvApp/build.gradle.kts && git commit -m "$(cat <<'EOF' +cd /Users/dev/projects/prairie/prairie-android && git add androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigration.kt androidTvApp/src/androidUnitTest/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigrationTest.kt androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/settings/TvSettingsViewModel.kt androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/libraries/TvLibrariesViewModel.kt androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt androidTvApp/build.gradle.kts && git commit -m "$(cat <<'EOF' Restore one-shot legacy tv_prefs migration on TV The branch dropped main's first-boot import of the legacy tv_prefs @@ -3342,8 +3342,8 @@ EOF ### Task D3: Constrain Palette extraction decode size in AmbientBackdropTint **Files:** -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/components/AmbientBackdropTint.kt` -- Test: none (existing `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidUnitTest/kotlin/com/continuum/app/tv/ui/components/AmbientBackdropTintStateTest.kt` continues to cover the state holder) +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/components/AmbientBackdropTint.kt` +- Test: none (existing `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidUnitTest/kotlin/com/continuum/app/tv/ui/components/AmbientBackdropTintStateTest.kt` continues to cover the state holder) - [ ] **Step 1: Write the failing test** @@ -3355,13 +3355,13 @@ Manual check: build, install, open Home on a TV device, and D-pad across hero ca Not applicable (no new unit test). Baseline instead: ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :androidTvApp:testDebugUnitTest +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :androidTvApp:testDebugUnitTest ``` Expected: current suite green before the change (so any post-change failure is attributable to this edit). - [ ] **Step 3: Implementation** -In `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/components/AmbientBackdropTint.kt`, replace lines 93–98. +In `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/components/AmbientBackdropTint.kt`, replace lines 93–98. Before: @@ -3397,14 +3397,14 @@ No import changes — `size(int)` is a member of `coil3.request.ImageRequest.Bui - [ ] **Step 4: Run tests** ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :androidTvApp:testDebugUnitTest :androidTvApp:assembleDebug +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :androidTvApp:testDebugUnitTest :androidTvApp:assembleDebug ``` Expected: suite green (including `AmbientBackdropTintStateTest`), module compiles. Then run the Step 1 manual check on a device. - [ ] **Step 5: Commit** ```bash -cd /Users/dev/projects/silo/prairie-android && git add androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/components/AmbientBackdropTint.kt && git commit -m "$(cat <<'EOF' +cd /Users/dev/projects/prairie/prairie-android && git add androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/components/AmbientBackdropTint.kt && git commit -m "$(cat <<'EOF' Cap ambient backdrop Palette decode at 128px The accent-extraction ImageRequest used allowHardware(false) with no @@ -4220,13 +4220,13 @@ EOF ### Task F1: Shared request presentation policy (mobile + TV) **Files:** -- Create: /Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/request/RequestPresentation.kt -- Test: /Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/request/RequestPresentationTest.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/requests/RequestComponents.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/requests/RequestDetailScreen.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/requests/MyRequestsScreen.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/requests/TvRequestComponents.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/requests/TvMyRequestsScreen.kt +- Create: /Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/request/RequestPresentation.kt +- Test: /Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/request/RequestPresentationTest.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/requests/RequestComponents.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/requests/RequestDetailScreen.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/requests/MyRequestsScreen.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/requests/TvRequestComponents.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/requests/TvMyRequestsScreen.kt Background (verified): `requestImageUrl`/`requestPosterUrl` and the cancel predicate are byte-identical in `RequestComponents.kt` (237–248, 270–271) and `TvRequestComponents.kt` (294–306). Badge precedence is duplicated with drift: mobile `badgeText()` (262–268) returns prettified text directly; TV `cardChipText()` (308–314) returns raw lowercase tokens that `TvRequestStatusChip` later prettifies via `requestLabel()` (321–331). `targetSummary` drift: mobile truncates with `"…"` (275), TV with `"..."` (335). The unification below keeps TV's rendered output identical (its chip still receives raw tokens and prettifies them), and unifies truncation on `"…"`. @@ -4553,10 +4553,10 @@ Co-Authored-By: Claude Fable 5 " ### Task F2: ApiResult.errorMessage — collapse 14 duplicated error branches in RequestsViewModels **Files:** -- Modify: /Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/ApiResult.kt -- Modify: /Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/viewmodel/RequestsViewModels.kt -- Test: /Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/ApiResultErrorMessageTest.kt -- Test (extend): /Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/viewmodel/RequestsViewModelTest.kt +- Modify: /Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/ApiResult.kt +- Modify: /Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/viewmodel/RequestsViewModels.kt +- Test: /Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/ApiResultErrorMessageTest.kt +- Test (extend): /Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/viewmodel/RequestsViewModelTest.kt - [ ] **Step 1: Write the failing test** @@ -4758,13 +4758,13 @@ Co-Authored-By: Claude Fable 5 " ### Task F3: One formatBytes for androidApp (5 duplicates) **Files:** -- Create: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/util/Formatters.kt -- Test: /Users/dev/projects/silo/prairie-android/androidApp/src/androidUnitTest/kotlin/com/continuum/app/android/ui/util/FormattersTest.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/downloads/DownloadEntryRows.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/downloads/DownloadsScreen.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/downloads/DownloadItemRow.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/player/QualitySelector.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/detail/MediaInfoSheet.kt +- Create: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/util/Formatters.kt +- Test: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidUnitTest/kotlin/com/continuum/app/android/ui/util/FormattersTest.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/downloads/DownloadEntryRows.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/downloads/DownloadsScreen.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/downloads/DownloadItemRow.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/player/QualitySelector.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/detail/MediaInfoSheet.kt Verified inventory (corrects one earlier note): the log10/1024 `"%.1f %s"` variant is byte-identical in `DownloadEntryRows.kt:467`, `DownloadsScreen.kt:387`, `DownloadItemRow.kt:105`. **Both** `QualitySelector.kt:141` and `MediaInfoSheet.kt:182` are when-ladder variants (no TB; `"%.1f GB"/"%.0f MB"/"%.0f KB"` and `"%.2f GB"/"%.1f MB"/"%.0f KB"` respectively) — their output changes slightly to the unified `"%.1f "` format (e.g. "250 MB" → "250.0 MB", "1.25 GB" → "1.2 GB"); acceptable. @@ -4887,12 +4887,12 @@ Co-Authored-By: Claude Fable 5 " ### Task F4: One formatClockTime for androidApp (4 drifted clones) **Files:** -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/util/Formatters.kt -- Test: /Users/dev/projects/silo/prairie-android/androidApp/src/androidUnitTest/kotlin/com/continuum/app/android/ui/util/FormattersTest.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/player/PlayerProgressBar.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/player/ChaptersSheet.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/audiobook/AudiobookPlayerScreen.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/audiobook/AudiobookBookmarksSheet.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/util/Formatters.kt +- Test: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidUnitTest/kotlin/com/continuum/app/android/ui/util/FormattersTest.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/player/PlayerProgressBar.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/player/ChaptersSheet.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/audiobook/AudiobookPlayerScreen.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/audiobook/AudiobookBookmarksSheet.kt Drift being unified: `PlayerProgressBar.formatTime` rounds (`roundToInt`), the other three truncate; only `ChaptersSheet.formatChapterTime` guards NaN. Unified semantics: truncation (`toLong`) + NaN/negative guard. The only visible change is the video player's clock, which may read 1 s lower at half-second boundaries; chapter/audiobook output is bit-identical. @@ -5001,7 +5001,7 @@ Co-Authored-By: Claude Fable 5 " ### Task F5: Collapse SeriesRow/SeasonRow into one ExpandableAggregateRow **Files:** -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/downloads/DownloadEntryRows.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/downloads/DownloadEntryRows.kt - Test: none new The sealed `DownloadEntry` base (DownloadsViewModel.kt:65–76) exposes `id/title/subtitle/posterUrl/posterThumbhash/totalBytesUsed/progress/isComplete`, so one composable can serve both; only child selection differs. @@ -5118,13 +5118,13 @@ Co-Authored-By: Claude Fable 5 " ### Task F6: ScopedJsonFileStore — shared skeleton for the three scoped JSON stores **Files:** -- Create: /Users/dev/projects/silo/prairie-android/android-shared/src/androidMain/kotlin/com/continuum/app/common/store/ScopedJsonFileStore.kt -- Test: /Users/dev/projects/silo/prairie-android/android-shared/src/androidUnitTest/kotlin/com/continuum/app/common/store/ScopedJsonFileStoreTest.kt -- Test: /Users/dev/projects/silo/prairie-android/android-shared/src/androidUnitTest/kotlin/com/continuum/app/common/audiobook/AudiobookBookmarksStoreTest.kt -- Test (extend): /Users/dev/projects/silo/prairie-android/android-shared/src/androidUnitTest/kotlin/com/continuum/app/common/ebook/EbookLocalStateStoreTest.kt -- Modify: /Users/dev/projects/silo/prairie-android/android-shared/src/androidMain/kotlin/com/continuum/app/common/ebook/EbookLocalStateStore.kt -- Modify: /Users/dev/projects/silo/prairie-android/android-shared/src/androidMain/kotlin/com/continuum/app/common/audiobook/AudiobookPositionStore.kt -- Modify: /Users/dev/projects/silo/prairie-android/android-shared/src/androidMain/kotlin/com/continuum/app/common/audiobook/AudiobookBookmarksStore.kt +- Create: /Users/dev/projects/prairie/prairie-android/android-shared/src/androidMain/kotlin/com/continuum/app/common/store/ScopedJsonFileStore.kt +- Test: /Users/dev/projects/prairie/prairie-android/android-shared/src/androidUnitTest/kotlin/com/continuum/app/common/store/ScopedJsonFileStoreTest.kt +- Test: /Users/dev/projects/prairie/prairie-android/android-shared/src/androidUnitTest/kotlin/com/continuum/app/common/audiobook/AudiobookBookmarksStoreTest.kt +- Test (extend): /Users/dev/projects/prairie/prairie-android/android-shared/src/androidUnitTest/kotlin/com/continuum/app/common/ebook/EbookLocalStateStoreTest.kt +- Modify: /Users/dev/projects/prairie/prairie-android/android-shared/src/androidMain/kotlin/com/continuum/app/common/ebook/EbookLocalStateStore.kt +- Modify: /Users/dev/projects/prairie/prairie-android/android-shared/src/androidMain/kotlin/com/continuum/app/common/audiobook/AudiobookPositionStore.kt +- Modify: /Users/dev/projects/prairie/prairie-android/android-shared/src/androidMain/kotlin/com/continuum/app/common/audiobook/AudiobookBookmarksStore.kt Notes from reading the code: all three stores re-declare `Json { ignoreUnknownKeys = true }`, the `$serverId/$profileId/$contentId` path builder, runCatching-read-with-`Log.w`, and the tmp+rename write (no fsync). Dedupe drift: ebook `addBookmark` uses `.distinctBy { it.id }`; audiobook `add` appends plain — and additionally returns the *unsorted* appended list while persisting the sorted one. Both get fixed: dedupe-by-id everywhere, and `add` returns exactly what it persists. `android.util.Log` is safe at unit-test scope (`isReturnDefaultValues = true` in android-shared's testOptions). @@ -5575,7 +5575,7 @@ Co-Authored-By: Claude Fable 5 " ### Task F7: ReadingHubUiState.clearingLibraryContent() — centralize the 14-field reset **Files:** -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/reading/ReadingHubViewModel.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/reading/ReadingHubViewModel.kt - Test: none new - [ ] **Step 1: Write the failing test** — not unit-testable at reasonable cost: no `ReadingHubViewModel` test exists (androidApp unit tests cover only downloads/navigation/reader), and one would require fakes for `PersonalDataRepository`, `SectionRepository`, and `CatalogRepository`. This is a behavior-preserving consolidation of `copy(...)` lists; covered by the existing suite plus compilation: `./gradlew :androidApp:testDebugUnitTest :androidApp:compileDebugKotlinAndroid`. diff --git a/docs/superpowers/plans/2026-06-12-ebook-phase1-reader-locator.md b/docs/superpowers/plans/2026-06-12-ebook-phase1-reader-locator.md index 4ee18bb68..82b276509 100644 --- a/docs/superpowers/plans/2026-06-12-ebook-phase1-reader-locator.md +++ b/docs/superpowers/plans/2026-06-12-ebook-phase1-reader-locator.md @@ -111,7 +111,7 @@ Steps: - [ ] Run the test and confirm it FAILS to compile (the `ReaderLocator` symbol does not yet exist): ``` - cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.reader.ReaderLocatorTest" + cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.reader.ReaderLocatorTest" ``` Expected: build failure / unresolved reference `ReaderLocator`. @@ -204,7 +204,7 @@ Steps: - [ ] Run the test and confirm it PASSES: ``` - cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.reader.ReaderLocatorTest" + cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.reader.ReaderLocatorTest" ``` Expected: `BUILD SUCCESSFUL`, all 9 test methods green. @@ -212,7 +212,7 @@ Steps: - [ ] Commit: ``` - cd /Users/dev/projects/silo/prairie-android && git add shared/src/commonMain/kotlin/com/continuum/app/model/reader/ReaderLocator.kt shared/src/commonTest/kotlin/com/continuum/app/model/reader/ReaderLocatorTest.kt && git commit -m "feat(reader): add ReaderLocator sealed type with typed JSON + legacy page:N parse" + cd /Users/dev/projects/prairie/prairie-android && git add shared/src/commonMain/kotlin/com/continuum/app/model/reader/ReaderLocator.kt shared/src/commonTest/kotlin/com/continuum/app/model/reader/ReaderLocatorTest.kt && git commit -m "feat(reader): add ReaderLocator sealed type with typed JSON + legacy page:N parse" ``` --- @@ -256,7 +256,7 @@ Steps: - [ ] Run the test and confirm it FAILS (unresolved `progressLocationForPage`, and `ebookPageNumberFromProgressLocation` does not yet understand typed JSON): ``` - cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.ebook.EbookVersionSelectionTest" + cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.ebook.EbookVersionSelectionTest" ``` Expected: compile failure on `progressLocationForPage` (and, once that is added, a failing assertion on the typed-JSON case until the body is updated). @@ -281,7 +281,7 @@ Steps: - [ ] Run the test and confirm it PASSES: ``` - cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.ebook.EbookVersionSelectionTest" + cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.ebook.EbookVersionSelectionTest" ``` Expected: `BUILD SUCCESSFUL`, including the three new methods and all pre-existing `EbookVersionSelectionTest` cases. @@ -289,7 +289,7 @@ Steps: - [ ] Commit: ``` - cd /Users/dev/projects/silo/prairie-android && git add shared/src/commonMain/kotlin/com/continuum/app/model/ebook/EbookVersionSelection.kt shared/src/commonTest/kotlin/com/continuum/app/model/ebook/EbookVersionSelectionTest.kt && git commit -m "feat(reader): resolve progress location via ReaderLocator (typed JSON + legacy page:N)" + cd /Users/dev/projects/prairie/prairie-android && git add shared/src/commonMain/kotlin/com/continuum/app/model/ebook/EbookVersionSelection.kt shared/src/commonTest/kotlin/com/continuum/app/model/ebook/EbookVersionSelectionTest.kt && git commit -m "feat(reader): resolve progress location via ReaderLocator (typed JSON + legacy page:N)" ``` --- @@ -346,7 +346,7 @@ Steps: - [ ] Run the test: ``` - cd /Users/dev/projects/silo/prairie-android && ./gradlew :android-shared:testDebugUnitTest --tests "com.continuum.app.common.ebook.EbookLocalStateStoreTest" + cd /Users/dev/projects/prairie/prairie-android && ./gradlew :android-shared:testDebugUnitTest --tests "com.continuum.app.common.ebook.EbookLocalStateStoreTest" ``` Expected: `BUILD SUCCESSFUL`. (`android-shared` already depends on `:shared` via `implementation(project(":shared"))`, so the `ReaderLocator` import resolves. If it does not compile, that signals the store module is missing the shared dependency — it is not — so the expected outcome is green with no production edit.) @@ -356,7 +356,7 @@ Steps: - [ ] Commit: ``` - cd /Users/dev/projects/silo/prairie-android && git add android-shared/src/androidUnitTest/kotlin/com/continuum/app/common/ebook/EbookLocalStateStoreTest.kt && git commit -m "test(reader): assert EbookLocalStateStore reads legacy and typed locator progress rows" + cd /Users/dev/projects/prairie/prairie-android && git add android-shared/src/androidUnitTest/kotlin/com/continuum/app/common/ebook/EbookLocalStateStoreTest.kt && git commit -m "test(reader): assert EbookLocalStateStore reads legacy and typed locator progress rows" ``` --- @@ -408,7 +408,7 @@ Steps: - [ ] Compile `androidApp` to confirm the refactor type-checks: ``` - cd /Users/dev/projects/silo/prairie-android && ./gradlew :androidApp:compileDebugKotlin + cd /Users/dev/projects/prairie/prairie-android && ./gradlew :androidApp:compileDebugKotlin ``` Expected: `BUILD SUCCESSFUL`. @@ -416,7 +416,7 @@ Steps: - [ ] Run the full affected module suites to confirm nothing regressed: ``` - cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest :android-shared:testDebugUnitTest :androidApp:testDebugUnitTest + cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest :android-shared:testDebugUnitTest :androidApp:testDebugUnitTest ``` Expected: `BUILD SUCCESSFUL` across all three. @@ -424,7 +424,7 @@ Steps: - [ ] Commit: ``` - cd /Users/dev/projects/silo/prairie-android && git add androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/reader/ReaderViewModel.kt && git commit -m "refactor(reader): persist progress + bookmarks as typed ReaderLocator JSON" + cd /Users/dev/projects/prairie/prairie-android && git add androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/reader/ReaderViewModel.kt && git commit -m "refactor(reader): persist progress + bookmarks as typed ReaderLocator JSON" ``` --- @@ -443,7 +443,7 @@ Steps: - [ ] Verify no inline `"page:` string literals remain in the reader path except the legacy-parse branch inside `ReaderLocator.parse`: ``` - cd /Users/dev/projects/silo/prairie-android && /usr/bin/grep -rn "page:" androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/reader shared/src/commonMain/kotlin/com/continuum/app/model + cd /Users/dev/projects/prairie/prairie-android && /usr/bin/grep -rn "page:" androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/reader shared/src/commonMain/kotlin/com/continuum/app/model ``` Expected: the only `"page:"` producers are `EpubReader.kt`'s `ReaderSection(location = "page:$index")` (section TOC anchors, untouched in Phase 1 — acceptable, parsed by the locator) and the legacy branch in `ReaderLocator.kt` / `EbookVersionSelection` comments. Note any others and fix if they bypass the helper. @@ -451,7 +451,7 @@ Steps: - [ ] Run lint on changed Kotlin (the repo's standard pre-MR gate): ``` - cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:lintKotlinCommonMain :androidApp:lintDebug + cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:lintKotlinCommonMain :androidApp:lintDebug ``` If these Gradle lint tasks are not configured in this repo, fall back to the project's documented lint command and run that instead; do not invent a task name. Fix any reported issues inline. @@ -459,7 +459,7 @@ Steps: - [ ] Final full run to confirm everything is green together: ``` - cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest :android-shared:testDebugUnitTest :androidApp:testDebugUnitTest + cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest :android-shared:testDebugUnitTest :androidApp:testDebugUnitTest ``` Expected: `BUILD SUCCESSFUL`. @@ -467,7 +467,7 @@ Steps: - [ ] If self-review surfaced any fix, commit it: ``` - cd /Users/dev/projects/silo/prairie-android && git add -A && git commit -m "chore(reader): phase 1 self-review fixes for ReaderLocator" + cd /Users/dev/projects/prairie/prairie-android && git add -A && git commit -m "chore(reader): phase 1 self-review fixes for ReaderLocator" ``` --- diff --git a/docs/superpowers/plans/2026-06-12-ebook-phase2-paginated-epub.md b/docs/superpowers/plans/2026-06-12-ebook-phase2-paginated-epub.md index 17ee7dffc..7cf83ccb1 100644 --- a/docs/superpowers/plans/2026-06-12-ebook-phase2-paginated-epub.md +++ b/docs/superpowers/plans/2026-06-12-ebook-phase2-paginated-epub.md @@ -289,7 +289,7 @@ The web side. Owns rendering, theming, tap/swipe zones, and posting typed events applyTheme: function (themeJson) { if (!rendition) return; var t = JSON.parse(themeJson); // {bg,fg,fontPercent,marginEm,fontFamily} - rendition.themes.register("silo", { + rendition.themes.register("prairie", { "body": { "background": t.bg, "color": t.fg, "margin": t.marginEm + "em !important", @@ -298,7 +298,7 @@ The web side. Owns rendering, theming, tap/swipe zones, and posting typed events }, "img": { "max-width": "100% !important", "height": "auto !important" } }); - rendition.themes.select("silo"); + rendition.themes.select("prairie"); rendition.themes.fontSize(t.fontPercent + "%"); } }; diff --git a/docs/superpowers/plans/2026-06-12-ebook-phase4-highlights-notes.md b/docs/superpowers/plans/2026-06-12-ebook-phase4-highlights-notes.md index 4f60fe3f4..ac93bafc9 100644 --- a/docs/superpowers/plans/2026-06-12-ebook-phase4-highlights-notes.md +++ b/docs/superpowers/plans/2026-06-12-ebook-phase4-highlights-notes.md @@ -98,7 +98,7 @@ This plan implements **Phase 4 only** of `docs/superpowers/specs/2026-06-12-eboo > **CHECK constraint name note:** The original inline `CHECK (...)` in `20260608000300_ebook_reader_state.sql` is unnamed, so Postgres auto-names it `ebook_reader_annotations_check`. The `DROP CONSTRAINT IF EXISTS ebook_reader_annotations_check` above targets that auto-name. Verify the live name before relying on the Down path: > ``` > make migrate-up - > docker compose exec -T postgres psql -U silo -d silo -c "\d+ ebook_reader_annotations" | grep -i check + > docker compose exec -T postgres psql -U prairie -d prairie -c "\d+ ebook_reader_annotations" | grep -i check > ``` > If the auto-name differs in the target DB, adjust the `DROP CONSTRAINT IF EXISTS` argument in the Up block before applying. The `IF EXISTS` guard keeps the migration safe either way. - [ ] Validate annotations parse without a DB: @@ -113,7 +113,7 @@ This plan implements **Phase 4 only** of `docs/superpowers/specs/2026-06-12-eboo ``` - [ ] Manually verify down/up round-trip in dev (do NOT do this against prod): ``` - go run ./cmd/silo/ --env .env --migrate-down-one # if supported; else use goose directly per Makefile GOOSE var + go run ./cmd/prairie/ --env .env --migrate-down-one # if supported; else use goose directly per Makefile GOOSE var make migrate-up ``` Confirm `\d+ ebook_reader_annotations` shows `locator_range jsonb` and the `_anchor_check` constraint after re-up. diff --git a/docs/superpowers/plans/2026-06-12-ebook-phase5-search.md b/docs/superpowers/plans/2026-06-12-ebook-phase5-search.md index 039b9b045..129abf56c 100644 --- a/docs/superpowers/plans/2026-06-12-ebook-phase5-search.md +++ b/docs/superpowers/plans/2026-06-12-ebook-phase5-search.md @@ -374,7 +374,7 @@ window.siloHighlightCfi = function (cfi) { window.siloLastHl = cfi; window.siloRendition.display(cfi).then(function () { window.siloRendition.annotations.highlight(cfi, {}, function () {}, - "silo-search-hl", { "fill": "yellow", "fill-opacity": "0.4" }); + "prairie-search-hl", { "fill": "yellow", "fill-opacity": "0.4" }); }); }; diff --git a/docs/superpowers/plans/2026-06-12-notifications.md b/docs/superpowers/plans/2026-06-12-notifications.md index a4338caec..682d726b3 100644 --- a/docs/superpowers/plans/2026-06-12-notifications.md +++ b/docs/superpowers/plans/2026-06-12-notifications.md @@ -21,9 +21,9 @@ I have all the conventions and wire shapes pinned. Note one wire detail: `Payloa ### Task S1: Add ktor-client-websockets dependency + install WebSockets plugin **Files:** -- Modify: `/Users/dev/projects/silo/prairie-android/gradle/libs.versions.toml` -- Modify: `/Users/dev/projects/silo/prairie-android/shared/build.gradle.kts` -- Modify: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/ContinuumHttpClientImpl.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/gradle/libs.versions.toml` +- Modify: `/Users/dev/projects/prairie/prairie-android/shared/build.gradle.kts` +- Modify: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/ContinuumHttpClientImpl.kt` This is an enabling task. It has no dedicated unit test; verification is a successful shared compile, which proves the new plugin install does not break the existing client config (ContentNegotiation, Auth, Logging, Timeout, defaultRequest). @@ -86,8 +86,8 @@ Co-Authored-By: Claude Fable 5 " ### Task S2: NotificationModels.kt (REST + preference + capability + realtime frame models) **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/notifications/NotificationModels.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/notifications/NotificationModelsSerializationTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/notifications/NotificationModels.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/notifications/NotificationModelsSerializationTest.kt` Evidence — pinned Go wire shapes (prairie-server `origin/feat/notifications-v1`): @@ -727,9 +727,9 @@ Co-Authored-By: Claude Fable 5 " ### Task S3: NotificationsApi.kt (REST + ws-ticket) + NetworkModule registration **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/api/NotificationsApi.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/NetworkModule.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/api/NotificationsApiTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/api/NotificationsApi.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/NetworkModule.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/api/NotificationsApiTest.kt` Evidence — routes (router.go, all under `/api/v1`, `RequireProfile`): `GET /notifications` (`status=unread`, `limit`, `before`), `GET /notifications/sync` (`since`, `limit`), `GET /notifications/{id}`, `GET /notifications/unread-count`, `GET /notifications/capability`, `GET /notifications/preferences`, `PUT /notifications/preferences`, `POST /notifications/{id}/read` (204), `POST /notifications/read-all` (204), `POST /events/ws-ticket`. @@ -1108,8 +1108,8 @@ Co-Authored-By: Claude Fable 5 " ### Task S4: NotificationsRealtimeClient.kt (ticket handshake + frame decode → Flow) **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/NotificationsRealtimeClient.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/NotificationRealtimeDecoderTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/NotificationsRealtimeClient.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/NotificationRealtimeDecoderTest.kt` Design: socket I/O is intentionally thin and untested; the load-bearing logic is the pure `decodeRealtimeFrame(json, raw): NotificationRealtimeEvent?` function, which gets full coverage (snapshot / created / read-one / read-all / unknown→null / non-notifications channel→null / malformed→null). Frame `data` shapes come straight from events_ws.go: snapshot `data` is a `[]DeliveryRowPayload`, `notification.created` `data` is one `DeliveryRowPayload`, `notification.read` `data` is `{profile_id,id}` or `{profile_id,all:true}`. @@ -1415,9 +1415,9 @@ Co-Authored-By: Claude Fable 5 " ### Task S5: NotificationsRepository.kt (singleton StateFlows + pure applyEvent fold) **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/repository/NotificationsRepository.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/RepositoryModule.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/repository/NotificationsRepositoryTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/repository/NotificationsRepository.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/RepositoryModule.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/repository/NotificationsRepositoryTest.kt` Design: REST is the source of truth; `refresh()`/`loadMore()` drive the lists; `connectRealtime(scope)` collects the realtime client flow with capped-backoff reconnect and folds events into the same StateFlows. The fold is a pure top-level `applyEvent(state, event): NotificationsState` plus a pure `recomputeUnread(rows): Int`, both fully unit-tested. The socket reconnect loop and lifecycle wiring are thin and exercised only via a fake flow under `runTest`. diff --git a/docs/superpowers/plans/2026-06-12-subtitle-suite.md b/docs/superpowers/plans/2026-06-12-subtitle-suite.md index a37d79272..a712f3c93 100644 --- a/docs/superpowers/plans/2026-06-12-subtitle-suite.md +++ b/docs/superpowers/plans/2026-06-12-subtitle-suite.md @@ -1537,7 +1537,7 @@ class SubtitleTrackMergeTest { releaseName = "Dune Part Three", provider = "subdl"), ), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) assertEquals(4, merged.size) @@ -1552,12 +1552,12 @@ class SubtitleTrackMergeTest { assertNull(first.forced) // Web: buildPlayerStreamUrl(apiBaseUrl, `/stream/${sid}/subtitles/${index}`, …) // with apiBaseUrl "/api/v1", absolutized against the server origin on Android. - assertEquals("https://silo.example/api/v1/stream/sess-1/subtitles/2", first.url) + assertEquals("https://prairie.example/api/v1/stream/sess-1/subtitles/2", first.url) val second = merged[3] assertEquals(3, second.index) assertEquals("Dune Part Three (subdl)", second.label) - assertEquals("https://silo.example/api/v1/stream/sess-1/subtitles/3", second.url) + assertEquals("https://prairie.example/api/v1/stream/sess-1/subtitles/3", second.url) } @Test @@ -1568,7 +1568,7 @@ class SubtitleTrackMergeTest { existing = existing, downloaded = emptyList(), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) assertSame(existing, merged) @@ -1580,7 +1580,7 @@ class SubtitleTrackMergeTest { existing = listOf(track(0, source = "embedded")), downloaded = listOf(downloaded(id = 312)), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) assertEquals(2, firstMerge.size) @@ -1588,7 +1588,7 @@ class SubtitleTrackMergeTest { existing = firstMerge, downloaded = listOf(downloaded(id = 312), downloaded(id = 313)), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) assertEquals(3, secondMerge.size) // 1 embedded + 2 downloaded, no duplicate of 312 @@ -1605,11 +1605,11 @@ class SubtitleTrackMergeTest { existing = existing, downloaded = listOf(downloaded(id = 312)), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) assertEquals(4, merged.last().index) // max(0,3)+1, not size (2) - assertEquals("https://silo.example/api/v1/stream/sess-1/subtitles/4", merged.last().url) + assertEquals("https://prairie.example/api/v1/stream/sess-1/subtitles/4", merged.last().url) } @Test @@ -1618,11 +1618,11 @@ class SubtitleTrackMergeTest { existing = emptyList(), downloaded = listOf(downloaded(id = 312)), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) assertEquals(0, merged.single().index) - assertEquals("https://silo.example/api/v1/stream/sess-1/subtitles/0", merged.single().url) + assertEquals("https://prairie.example/api/v1/stream/sess-1/subtitles/0", merged.single().url) } @Test @@ -1631,10 +1631,10 @@ class SubtitleTrackMergeTest { existing = emptyList(), downloaded = listOf(downloaded(id = 312)), sessionId = "sess-1", - serverUrl = "https://silo.example/", + serverUrl = "https://prairie.example/", ) - assertEquals("https://silo.example/api/v1/stream/sess-1/subtitles/0", merged.single().url) + assertEquals("https://prairie.example/api/v1/stream/sess-1/subtitles/0", merged.single().url) } } ``` @@ -3664,9 +3664,9 @@ TV-side facts these tasks build on (verified against current code): `TvPlayerVie ### Task T1: TvPlayerViewModel — aiStatus probe, refreshSubtitles merge + Media3 rebuild, search/download/translate orchestration **Files:** -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvPlayerViewModel.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvPlayerScreen.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvPlayerViewModel.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvPlayerScreen.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt` - [ ] **Step 1: Write the failing test** @@ -4173,9 +4173,9 @@ Co-Authored-By: Claude Fable 5 " ### Task T2: TvSubtitleSearchDialog + HUD "Search subtitles" action row **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvSubtitleSearchDialog.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvPlayerHud.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvPlayerScreen.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvSubtitleSearchDialog.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvPlayerHud.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvPlayerScreen.kt` - [ ] **Step 1: Write the failing test** @@ -4915,8 +4915,8 @@ Co-Authored-By: Claude Fable 5 " ### Task T3: TvAiTranslateDialog + HUD "Translate with AI" row (AI-status gated) **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvAiTranslateDialog.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvPlayerScreen.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvAiTranslateDialog.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvPlayerScreen.kt` - [ ] **Step 1: Write the failing test** diff --git a/docs/superpowers/plans/2026-06-12-watch-together.md b/docs/superpowers/plans/2026-06-12-watch-together.md index 1a5cb6a38..dff145189 100644 --- a/docs/superpowers/plans/2026-06-12-watch-together.md +++ b/docs/superpowers/plans/2026-06-12-watch-together.md @@ -19,12 +19,12 @@ I have all contracts confirmed. The server uses 201 on create suggestion (not ju ### Task S1: Watch Together shared models + serialization **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/watchtogether/WatchTogetherModels.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/watchtogether/WatchTogetherModelsSerializationTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/watchtogether/WatchTogetherModels.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/watchtogether/WatchTogetherModelsSerializationTest.kt` - [ ] **Step 1: Write the failing test** (full code) -`/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/watchtogether/WatchTogetherModelsSerializationTest.kt` +`/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/watchtogether/WatchTogetherModelsSerializationTest.kt` ```kotlin package com.continuum.app.model.watchtogether @@ -408,7 +408,7 @@ Expected: compilation failure — `WatchTogetherModels.kt` and `WatchTogetherRea - [ ] **Step 3: Implementation** (complete code) -`/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/watchtogether/WatchTogetherModels.kt` +`/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/watchtogether/WatchTogetherModels.kt` ```kotlin package com.continuum.app.model.watchtogether @@ -734,7 +734,7 @@ data class WsError( ) ``` -`/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/WatchTogetherRealtimeEvent.kt` +`/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/WatchTogetherRealtimeEvent.kt` ```kotlin package com.continuum.app.network @@ -796,13 +796,13 @@ Co-Authored-By: Claude Fable 5 " ### Task S2: WatchTogetherApi (REST) **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/api/WatchTogetherApi.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/NetworkModule.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/api/WatchTogetherApiTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/api/WatchTogetherApi.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/NetworkModule.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/api/WatchTogetherApiTest.kt` - [ ] **Step 1: Write the failing test** (full code) -`/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/api/WatchTogetherApiTest.kt` +`/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/api/WatchTogetherApiTest.kt` ```kotlin package com.continuum.app.network.api @@ -1030,7 +1030,7 @@ Expected: compilation failure — `WatchTogetherApi` / `DefaultWatchTogetherApi` - [ ] **Step 3: Implementation** (complete code) -`/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/api/WatchTogetherApi.kt` +`/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/api/WatchTogetherApi.kt` ```kotlin package com.continuum.app.network.api @@ -1251,7 +1251,7 @@ class DefaultWatchTogetherApi(private val client: HttpClient) : WatchTogetherApi } ``` -Modify `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/NetworkModule.kt` — add inside the `module { … }` block after the AdminApi line: +Modify `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/NetworkModule.kt` — add inside the `module { … }` block after the AdminApi line: ```kotlin single { DefaultAdminApi(get()) } single { DefaultWatchTogetherApi(get()) } @@ -1278,12 +1278,12 @@ Co-Authored-By: Claude Fable 5 " ### Task S3: WatchTogetherRealtimeClient + decodeRoomFrame **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/WatchTogetherRealtimeClient.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/RoomFrameDecoderTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/WatchTogetherRealtimeClient.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/RoomFrameDecoderTest.kt` - [ ] **Step 1: Write the failing test** (full code) -`/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/RoomFrameDecoderTest.kt` +`/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/RoomFrameDecoderTest.kt` ```kotlin package com.continuum.app.network @@ -1404,7 +1404,7 @@ Expected: compilation failure — `decodeRoomFrame` and `WatchTogetherRealtimeCl - [ ] **Step 3: Implementation** (complete code) -`/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/WatchTogetherRealtimeClient.kt` +`/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/WatchTogetherRealtimeClient.kt` ```kotlin package com.continuum.app.network @@ -1650,12 +1650,12 @@ Co-Authored-By: Claude Fable 5 " ### Task S4: RoomSyncEngine (pure timing brain) **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/RoomSyncEngine.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/RoomSyncEngineTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/RoomSyncEngine.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/RoomSyncEngineTest.kt` - [ ] **Step 1: Write the failing test** (full code) -`/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/RoomSyncEngineTest.kt` +`/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/RoomSyncEngineTest.kt` ```kotlin package com.continuum.app @@ -1919,7 +1919,7 @@ Expected: compilation failure — `RoomSyncEngine` does not exist (unresolved re - [ ] **Step 3: Implementation** (complete code) -`/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/RoomSyncEngine.kt` +`/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/RoomSyncEngine.kt` ```kotlin package com.continuum.app @@ -2074,13 +2074,13 @@ Co-Authored-By: Claude Fable 5 " ### Task S5: WatchTogetherRepository (singleton) **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/repository/WatchTogetherRepository.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/RepositoryModule.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/repository/WatchTogetherRepositoryTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/repository/WatchTogetherRepository.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/RepositoryModule.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/repository/WatchTogetherRepositoryTest.kt` - [ ] **Step 1: Write the failing test** (full code) -`/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/repository/WatchTogetherRepositoryTest.kt` +`/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/repository/WatchTogetherRepositoryTest.kt` ```kotlin package com.continuum.app.repository @@ -2319,7 +2319,7 @@ Expected: compilation failure — `WatchTogetherRepository` does not exist (unre - [ ] **Step 3: Implementation** (complete code) -`/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/repository/WatchTogetherRepository.kt` +`/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/repository/WatchTogetherRepository.kt` ```kotlin package com.continuum.app.repository @@ -2591,7 +2591,7 @@ class WatchTogetherRepository( } ``` -Modify `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/RepositoryModule.kt` — add the import and the singleton. Add to the imports block: +Modify `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/RepositoryModule.kt` — add the import and the singleton. Add to the imports block: ```kotlin import com.continuum.app.repository.WatchTogetherRepository ``` diff --git a/docs/superpowers/plans/2026-06-13-shared-video-player-core.md b/docs/superpowers/plans/2026-06-13-shared-video-player-core.md index 1d287f0a0..258fab272 100644 --- a/docs/superpowers/plans/2026-06-13-shared-video-player-core.md +++ b/docs/superpowers/plans/2026-06-13-shared-video-player-core.md @@ -82,7 +82,7 @@ androidTvApp/src/androidUnitTest/kotlin/com/continuum/app/tv/ui/screens/player/ - [ ] Run repository status and note unrelated local changes without reverting them. ```bash -cd /Users/jimcole/projects/silo/prairie-android +cd /Users/jimcole/projects/prairie/prairie-android git status --short git branch --show-current ``` @@ -1669,7 +1669,7 @@ Manual flow: SHIELD_ID=$(adb devices -l | awk 'tolower($0) ~ /device / && tolower($0) ~ /(shield|nvidia|android_tv)/ { print $1; exit }') test -n "$SHIELD_ID" adb -s "$SHIELD_ID" logcat -c -adb -s "$SHIELD_ID" logcat | rg -i "continuum|silo|player|subtitle|media3|exoplayer|resume|error|exception" +adb -s "$SHIELD_ID" logcat | rg -i "continuum|prairie|player|subtitle|media3|exoplayer|resume|error|exception" ``` Expected observation: @@ -1686,7 +1686,7 @@ Subtitle selection produces Media3 or app logs showing selected text track. PIXEL_ID=$(adb devices -l | awk 'tolower($0) ~ /device / && tolower($0) ~ /pixel/ { print $1; exit }') test -n "$PIXEL_ID" adb -s "$PIXEL_ID" logcat -c -adb -s "$PIXEL_ID" logcat | rg -i "continuum|silo|player|subtitle|media3|exoplayer|resume|error|exception" +adb -s "$PIXEL_ID" logcat | rg -i "continuum|prairie|player|subtitle|media3|exoplayer|resume|error|exception" ``` Expected observation: diff --git a/docs/superpowers/plans/2026-06-17-android-tv-detail-parity.md b/docs/superpowers/plans/2026-06-17-android-tv-detail-parity.md index 44f0cd58e..10bfdb2a7 100644 --- a/docs/superpowers/plans/2026-06-17-android-tv-detail-parity.md +++ b/docs/superpowers/plans/2026-06-17-android-tv-detail-parity.md @@ -11,7 +11,7 @@ **Per-task loop (every task):** implement → `./gradlew :androidTvApp:compileDebugKotlinAndroid` clean → run any unit tests → **Codex review of the diff** → commit (author `rxwatcher`, `Co-Authored-By: Claude Opus 4.8 (1M context) `, no push). Visual parity is verified on the **Android-TV emulator** against the reference frames (the physical Shield `screencap` returns black). **Conventions:** -- Apple repo lives at `/Users/jimcole/projects/silo/prairie-apple` (branch `feature/playback-ux-redesign`; `cd iosApp && xcodegen generate` before building `PrairieTV`). Read the cited Swift file for exact pt/opacity/scale values; translate per SPEC "Architecture" (unitless 1:1; radii face value; sizes → existing Android TV token scale, tuned on emulator). +- Apple repo lives at `/Users/jimcole/projects/prairie/prairie-apple` (branch `feature/playback-ux-redesign`; `cd iosApp && xcodegen generate` before building `PrairieTV`). Read the cited Swift file for exact pt/opacity/scale values; translate per SPEC "Architecture" (unitless 1:1; radii face value; sizes → existing Android TV token scale, tuned on emulator). - Add a dedicated control radius token `TvControlCorner = 8.dp` (do NOT reuse `small = 12.dp`). - Commit author: every commit uses `git -c user.name=rxwatcher commit …`. diff --git a/docs/superpowers/plans/2026-06-17-playback-behavior-migration.md b/docs/superpowers/plans/2026-06-17-playback-behavior-migration.md index 52d9d7009..a021ca677 100644 --- a/docs/superpowers/plans/2026-06-17-playback-behavior-migration.md +++ b/docs/superpowers/plans/2026-06-17-playback-behavior-migration.md @@ -2,7 +2,7 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Add three playback-behavior features to prairie-android — skip-back-on-resume, pass-out protection, and a remote session-control WebSocket — reusing silo's existing service-owned player. +**Goal:** Add three playback-behavior features to prairie-android — skip-back-on-resume, pass-out protection, and a remote session-control WebSocket — reusing prairie's existing service-owned player. **Architecture:** Pure logic lands in `shared`/`android-shared` (unit-testable, reusable by `androidTvApp`); UI stays in `androidApp`. The realtime socket mirrors the existing `WatchTogetherRealtimeClient` (shared, thin I/O + pure decode) bound to `PlayerViewModel` by a `PlaybackRealtimeController` that mirrors `RoomSyncController`. Server protocol already exists (`/sessions/{session_id}/control/ws`); the wire format matches `prairie-server/web/src/player/realtime-protocol.ts`. @@ -973,7 +973,7 @@ fun decidePlaybackAction(command: PlaybackRealtimeEvent.Command): PlaybackAction } ``` -> `JsonPrimitive.isString` distinguishes a JSON string from a bare number; `doubleOrNull` parses numeric primitives. Confirm `position_seconds` is the field the issuer sends (silo uses `position_seconds` consistently in watch-together); the decode test pins it. +> `JsonPrimitive.isString` distinguishes a JSON string from a bare number; `doubleOrNull` parses numeric primitives. Confirm `position_seconds` is the field the issuer sends (prairie uses `position_seconds` consistently in watch-together); the decode test pins it. - [ ] **Step 4: Run, verify it passes** @@ -1228,7 +1228,7 @@ git add -A && git commit -m "test(playback): subsystem A verification fixes" ## Notes for the implementer -- **No lift-and-shift from continuum.** Continuum used Decompose + an app-level ExoPlayer; silo uses Compose Navigation + a service-owned player. Mirror silo's own patterns (`WatchTogetherRealtimeClient`, `RoomSyncController`, `PlayerViewModel`). +- **No lift-and-shift from continuum.** Continuum used Decompose + an app-level ExoPlayer; prairie uses Compose Navigation + a service-owned player. Mirror prairie's own patterns (`WatchTogetherRealtimeClient`, `RoomSyncController`, `PlayerViewModel`). - **Reuse, don't duplicate.** Every remote-control method and event handler calls a method the VM already exposes. - **Out of scope (this plan):** `play_media`, `set_audio_track`, `set_subtitle_track`, `set_volume` actioning — these map to `Ignore`/`Reject` for now (the client does not advertise the track/media commands in `Supported`). - **TV adoption** of `AutoPlayGuard` and the rewound start position is specified in **Part B** below (the product owner asked Subsystem A to cover TV too). diff --git a/docs/superpowers/plans/2026-06-17-tv-mobile-parity.md b/docs/superpowers/plans/2026-06-17-tv-mobile-parity.md index d497cac8a..49f4cbf03 100644 --- a/docs/superpowers/plans/2026-06-17-tv-mobile-parity.md +++ b/docs/superpowers/plans/2026-06-17-tv-mobile-parity.md @@ -47,7 +47,7 @@ Legend: `[ ]` todo · `[x]` done (commit sha) · `[~]` partial · `[N/A]` won't- - [x] **P4.3 Admin create/edit user (TV)** — quick role/enable actions (setRole/setEnabled in shared AdminUsersViewModel) PLUS the full create/edit FORM: TvAdminUserEditScreen over shared AdminUserEditViewModel (username/email/password, role chips, enabled + download toggles, library-access ids, stream/transcode/profile quotas). Reachable from "Add user" row + "Edit user" dialog option. Nested AdminUserEdit(userId?) route (non-restoring nav); edit-mode derived from route userId; Save gated until edit-load; focus targets first editable field. Codex-reviewed. Commit a85e5a5. - [x] **P4.4 Request Detail (TV)** — parameterized route RequestDetail(mediaType,tmdbId) + DI(params) + TvRequestDetailScreen (reuses shared RequestDetailViewModel; title/meta/genres/overview + Request button[disabled while submitting]/status); reachable from TvRequestsScreen non-actionable taps. (Recommendations rail + poster art = follow-up.) - [x] **P4.5 My Requests open non-library rows (TV)** — both TvRequestsScreen + TvMyRequestsScreen non-library taps open Request Detail (rows always actionable, phone parity). -- [x] **P4.6 Pair Device (TV)** — TvPairDeviceScreen over shared DevicePairingViewModel (approve/deny by token deep link or manual code via TvTextInputDialog); top-level TvRoute.PairDevice(token,code) + DI factory + TvAppNavigation composable + pendingDeepLink "device" branch (prefers token, launchSingleTop); MainTvActivity + manifest now accept `silo` scheme; Settings "Pair a device" row. Codex-reviewed (button enabled-gating + launchSingleTop). Commit d16bde3. (HTTPS `/device` App Links = best-effort follow-up, same caveat as phone parser.) +- [x] **P4.6 Pair Device (TV)** — TvPairDeviceScreen over shared DevicePairingViewModel (approve/deny by token deep link or manual code via TvTextInputDialog); top-level TvRoute.PairDevice(token,code) + DI factory + TvAppNavigation composable + pendingDeepLink "device" branch (prefers token, launchSingleTop); MainTvActivity + manifest now accept `prairie` scheme; Settings "Pair a device" row. Codex-reviewed (button enabled-gating + launchSingleTop). Commit d16bde3. (HTTPS `/device` App Links = best-effort follow-up, same caveat as phone parser.) - [x] **P4.7 Manage Sessions (TV)** — TvManageSessionsViewModel/Screen (own sessions via AuthRepository getSessions/deleteSession) + route/DI; "Manage sessions" row in Settings Account. ## Phase 5 — Collections ✅ commit f88b63b (Codex-reviewed) diff --git a/docs/superpowers/plans/2026-07-16-fire-tv-sideload-launcher-icon.md b/docs/superpowers/plans/2026-07-16-fire-tv-sideload-launcher-icon.md index 2ae7a1b80..a178e1c23 100644 --- a/docs/superpowers/plans/2026-07-16-fire-tv-sideload-launcher-icon.md +++ b/docs/superpowers/plans/2026-07-16-fire-tv-sideload-launcher-icon.md @@ -161,25 +161,25 @@ Expected: `legacyTvLauncherIconsAreOpaqueSquaresAtRequiredDensities` fails becau Preserve the current highest-resolution blank gradient and colorful adaptive foreground before replacing targets: ```bash -cp androidTvApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png /tmp/silo-tv-icon-gradient.png -cp androidTvApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png /tmp/silo-tv-icon-mark.png +cp androidTvApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png /tmp/prairie-tv-icon-gradient.png +cp androidTvApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png /tmp/prairie-tv-icon-mark.png for density_and_size in mdpi:80 hdpi:120 xhdpi:160 xxhdpi:240 xxxhdpi:320; do density=${density_and_size%%:*} size=${density_and_size##*:} mark_height=$((size * 68 / 100)) - magick /tmp/silo-tv-icon-gradient.png \ + magick /tmp/prairie-tv-icon-gradient.png \ -gravity center -crop 360x360+0+0 +repage \ -resize "${size}x${size}!" \ - /tmp/silo-tv-icon-background.png + /tmp/prairie-tv-icon-background.png - magick /tmp/silo-tv-icon-mark.png \ + magick /tmp/prairie-tv-icon-mark.png \ -trim +repage -resize "x${mark_height}" \ - /tmp/silo-tv-icon-foreground.png + /tmp/prairie-tv-icon-foreground.png - magick /tmp/silo-tv-icon-background.png \ - /tmp/silo-tv-icon-foreground.png \ + magick /tmp/prairie-tv-icon-background.png \ + /tmp/prairie-tv-icon-foreground.png \ -gravity center -composite \ "PNG24:androidTvApp/src/androidMain/res/mipmap-${density}/ic_launcher.png" done @@ -190,10 +190,10 @@ Expected: five opaque square files with a centered colorful Prairie mark on the Generate the banner separately so Fire OS's centered square crop contains the complete canonical logo: ```bash -magick -size 320x180 canvas:'#1718c9' /tmp/silo-tv-banner-background.png +magick -size 320x180 canvas:'#1718c9' /tmp/prairie-tv-banner-background.png magick androidTvApp/src/androidMain/res/drawable/prairie_wordmark.png \ - -trim +repage -resize '180x' /tmp/silo-tv-banner-wordmark.png -magick /tmp/silo-tv-banner-background.png /tmp/silo-tv-banner-wordmark.png \ + -trim +repage -resize '180x' /tmp/prairie-tv-banner-wordmark.png +magick /tmp/prairie-tv-banner-background.png /tmp/prairie-tv-banner-wordmark.png \ -gravity center -composite \ 'PNG24:androidTvApp/src/androidMain/res/drawable/tv_banner.png' ``` @@ -277,7 +277,7 @@ Expected: installation succeeds and the Fire TV app grid opens. - [ ] **Step 3: Capture and inspect the Fire TV launcher** ```bash -adb -s "$FIRE_TV_SERIAL" exec-out screencap -p > /tmp/fire-tv-silo-square-launcher.png +adb -s "$FIRE_TV_SERIAL" exec-out screencap -p > /tmp/fire-tv-prairie-square-launcher.png ``` Expected: Fire OS retains its outer gray sideload frame, but the inner Prairie icon is square, the colorful mark is not compressed, and the mark is materially larger and sharper than before. @@ -296,7 +296,7 @@ Expected: installation succeeds and the Google TV launcher opens. - [ ] **Step 5: Capture and inspect the Google TV launcher** ```bash -adb -s "$GOOGLE_TV_SERIAL" exec-out screencap -p > /tmp/google-tv-silo-adaptive-launcher.png +adb -s "$GOOGLE_TV_SERIAL" exec-out screencap -p > /tmp/google-tv-prairie-adaptive-launcher.png ``` Expected: Google TV uses the existing adaptive icon with its normal launcher mask; there is no baked square border, stretched banner, or cropped mark. diff --git a/docs/superpowers/plans/2026-07-27-android-media-buffer-sizing.md b/docs/superpowers/plans/2026-07-27-android-media-buffer-sizing.md new file mode 100644 index 000000000..3c49c0a02 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-android-media-buffer-sizing.md @@ -0,0 +1,400 @@ +# Android Media-Aware Buffer Sizing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Size Android phone and TV byte buffers from encoded media bitrate metadata instead of fast-network delivery capacity whenever media metadata is available. + +**Architecture:** Add one pure internal bitrate-selection function beside `PrairieLoadControl`, represented by a small immutable per-track input. Production maps each selected Media3 track into that input; the function chooses average bitrate, then peak, sums known media rates, and only falls back to the largest network estimate when every media rate is unknown. The existing target-byte calculation, time thresholds, and retry data source remain unchanged. + +**Tech Stack:** Kotlin 2.1, AndroidX Media3 1.10.1, JUnit 4, Gradle, Android phone and TV application modules. + +## Global Constraints + +- Limit production behavior changes to shared `android-shared/PrairieLoadControl`. +- Do not change Prairie Server, Apple clients, or production proxy configuration. +- Preserve issue #80 HTTP Range resume/retry behavior. +- Preserve existing startup, rebuffer, and back-buffer time thresholds. +- Preserve the 16 MiB byte floor, device-specific byte caps, and 15 percent byte overhead. +- Use positive `Format.averageBitrate` first and positive `Format.peakBitrate` only when average is absent or invalid. +- Do not use `Format.bitrate` as an independent input. +- Use raw network throughput only when no selected track has valid media metadata. +- Do not claim observed-consumption adaptation because Media3 exposes no reliable encoded-consumption signal at this boundary. + +--- + +### Task 1: Characterize the approved bitrate-selection contract + +**Files:** +- Create: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControlTest.kt` +- Modify later in Task 2: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControl.kt` + +**Interfaces:** +- Consumes: proposed `BufferSizingTrackBitrates(averageBitrateBps: Int, peakBitrateBps: Int, latestNetworkEstimateBps: Long)` +- Produces: regression expectations for `selectBufferSizingBitrateBps(tracks: List): Long?` + +- [ ] **Step 1: Add failing average/peak precedence tests** + +Create `PrairieLoadControlTest.kt` with literal expectations: + +```kotlin +package org.prairieserver.prairie.common.player + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PrairieLoadControlTest { + @Test + fun `average bitrate takes precedence over peak bitrate`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates( + averageBitrateBps = 4_000_000, + peakBitrateBps = 9_000_000, + latestNetworkEstimateBps = 100_000_000L, + ), + ), + ) + + assertEquals(4_000_000L, selected) + } + + @Test + fun `peak bitrate is used when average bitrate is invalid`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates( + averageBitrateBps = -1, + peakBitrateBps = 9_000_000, + latestNetworkEstimateBps = 100_000_000L, + ), + ), + ) + + assertEquals(9_000_000L, selected) + } +} +``` + +- [ ] **Step 2: Add failing media aggregation and no-inflation tests** + +Extend the same test class: + +```kotlin + @Test + fun `known selected media bitrates are summed and network capacity is ignored`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates(4_000_000, 8_000_000, 100_000_000L), + BufferSizingTrackBitrates(-1, 192_000, 100_000_000L), + ), + ) + + assertEquals(4_192_000L, selected) + } + + @Test + fun `one known media rate suppresses network fallback from metadata-poor tracks`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates(4_000_000, 8_000_000, 100_000_000L), + BufferSizingTrackBitrates(-1, -1, 100_000_000L), + ), + ) + + assertEquals(4_000_000L, selected) + } +``` + +- [ ] **Step 3: Add failing last-resort and unknown tests** + +Extend the same test class: + +```kotlin + @Test + fun `largest network estimate is the last resort when all media metadata is invalid`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates(0, -1, 18_000_000L), + BufferSizingTrackBitrates(-1, 0, 25_000_000L), + ), + ) + + assertEquals(25_000_000L, selected) + } + + @Test + fun `unknown bitrate remains unknown when metadata and network estimates are invalid`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates(-1, 0, -1L), + ), + ) + + assertNull(selected) + } + + @Test + fun `empty track selection remains unknown`() { + assertNull(selectBufferSizingBitrateBps(emptyList())) + } +``` + +- [ ] **Step 4: Run the focused test to prove RED** + +Run: + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.player.PrairieLoadControlTest' \ + --max-workers=2 +``` + +Expected: compilation fails because `BufferSizingTrackBitrates` and `selectBufferSizingBitrateBps` do not exist. + +- [ ] **Step 5: Commit the RED tests** + +```bash +git add android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControlTest.kt +git commit -m "test(android): specify media-aware buffer bitrate selection" +``` + +### Task 2: Implement media-metadata-first selection + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControl.kt` +- Test: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControlTest.kt` + +**Interfaces:** +- Consumes: Media3 `ExoTrackSelection.selectedFormat` and `latestBitrateEstimate` +- Produces: `internal data class BufferSizingTrackBitrates` and `internal fun selectBufferSizingBitrateBps(List): Long?` + +- [ ] **Step 1: Add the minimal pure selector** + +Add beside the existing target-byte helper: + +```kotlin +internal data class BufferSizingTrackBitrates( + val averageBitrateBps: Int, + val peakBitrateBps: Int, + val latestNetworkEstimateBps: Long, +) + +internal fun selectBufferSizingBitrateBps( + tracks: List, +): Long? { + val mediaBitrateBps = + tracks + .mapNotNull { track -> + track.averageBitrateBps.takeIf { it > 0 }?.toLong() + ?: track.peakBitrateBps.takeIf { it > 0 }?.toLong() + } + + if (mediaBitrateBps.isNotEmpty()) { + return mediaBitrateBps.sum() + } + + return tracks + .maxOfOrNull { it.latestNetworkEstimateBps } + ?.takeIf { it > 0L } +} +``` + +- [ ] **Step 2: Route selected Media3 tracks through the selector** + +Replace the per-selection maximum with one selection-wide call: + +```kotlin +val selectedBitrateBps = + selectBufferSizingBitrateBps( + trackSelections.mapNotNull { selection -> + selection?.let { + BufferSizingTrackBitrates( + averageBitrateBps = it.selectedFormat.averageBitrate, + peakBitrateBps = it.selectedFormat.peakBitrate, + latestNetworkEstimateBps = it.latestBitrateEstimate, + ) + } + }, + ) +``` + +Delete the old private `ExoTrackSelection.selectedBitrateBps()` helper. Do not read `Format.bitrate`. + +- [ ] **Step 3: Run the focused selector and policy tests** + +Run: + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.player.PrairieLoadControlTest' \ + --tests 'org.prairieserver.prairie.common.player.PlaybackBufferPolicyTest' \ + --max-workers=2 +``` + +Expected: all tests pass. Existing floor/cap tests demonstrate the byte calculation is unchanged. + +- [ ] **Step 4: Perform the mutation check** + +Temporarily reason through these mutations without retaining source changes: + +- choosing peak before average fails `average bitrate takes precedence over peak bitrate`; +- adding network estimates to known media rates fails both no-inflation tests; +- summing shared network estimates fails `largest network estimate is the last resort`; +- accepting zero as known metadata fails the last-resort test; +- returning zero instead of `null` fails both unknown tests. + +- [ ] **Step 5: Commit the implementation** + +```bash +git add android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControl.kt +git commit -m "fix(android): size playback buffer from media bitrate" +``` + +### Task 3: Verify issue #80 compatibility and Android builds + +**Files:** +- No production files expected +- Inspect: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/ProgressiveDirectPlayResumeIntegrationTest.kt` + +**Interfaces:** +- Consumes: completed load-control change +- Produces: test and build evidence; no new API + +- [ ] **Step 1: Run the progressive Range-resume integration test** + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.player.ProgressiveDirectPlayResumeIntegrationTest' \ + --max-workers=2 +``` + +Expected: all resume/retry cases pass without changes. + +- [ ] **Step 2: Run the complete shared Android unit-test suite** + +```bash +./gradlew :android-shared:testDebugUnitTest --max-workers=2 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 3: Run phone and TV debug compilation** + +```bash +./gradlew \ + :androidApp:compileDebugKotlin \ + :androidTvApp:compileDebugKotlin \ + --max-workers=2 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Run phone and TV release assembly** + +```bash +./gradlew \ + :androidApp:assembleRelease \ + :androidTvApp:assembleRelease \ + --max-workers=2 +``` + +Expected: `BUILD SUCCESSFUL` with dependency verification intact. + +- [ ] **Step 5: Run repository formatting/static checks applicable to the changed Kotlin** + +Inspect available Gradle verification tasks and run the repository's configured Kotlin lint/format checks. At minimum run: + +```bash +./gradlew check --max-workers=2 +``` + +Expected: `BUILD SUCCESSFUL`. + +### Task 4: Perform focused canary and independent review + +**Files:** +- Modify only if a deterministic defect is found: the two files from Tasks 1–2 +- Record review or canary notes in the draft PR description rather than production code + +**Interfaces:** +- Consumes: green implementation branch +- Produces: review verdict and bounded runtime evidence + +- [ ] **Step 1: Exercise a short-timeout local canary if the existing playback harness can do so safely** + +Use an ephemeral local endpoint or the existing playback harness with deliberately short idle timeouts. Do not change production proxy settings. Confirm that a Range-capable direct-play request resumes or retries using the existing issue #80 path. + +If the fixture cannot create genuine socket backpressure, record exactly that limitation and rely on the integration test plus target-selection regression tests; do not substitute bandwidth or allocator growth as a proxy. + +- [ ] **Step 2: Request an independent code review** + +Ask a fresh reviewer to inspect: + +- compliance with the approved average-then-peak order; +- absence of `Format.bitrate` as an independent input; +- network fallback only when all media metadata is absent; +- integer overflow or malformed-metadata handling; +- preservation of load-control floors, caps, thresholds, and issue #80 retry code; +- whether tests would fail under each realistic wrong branch. + +- [ ] **Step 3: Apply only verified corrections test-first** + +For each actionable defect, first add or adjust a test that fails for that defect, run it to prove RED, make the smallest production correction, then rerun the focused and complete gates from Task 3. + +- [ ] **Step 4: Run final clean verification** + +From a clean worktree, rerun: + +```bash +./gradlew \ + :android-shared:testDebugUnitTest \ + :androidApp:compileDebugKotlin \ + :androidTvApp:compileDebugKotlin \ + :androidApp:assembleRelease \ + :androidTvApp:assembleRelease \ + --max-workers=2 +git diff --check +git status --short +``` + +Expected: Gradle and diff checks succeed; status contains no uncommitted implementation changes. + +### Task 5: Publish a separate draft pull request + +**Files:** +- No code changes expected + +**Interfaces:** +- Consumes: reviewed, green `fix/android-buffer-sizing` branch +- Produces: a draft GitHub pull request targeting current `main` + +- [ ] **Step 1: Review the branch diff and commits** + +```bash +git log --oneline origin/main..HEAD +git diff --stat origin/main...HEAD +git diff --check origin/main...HEAD +``` + +Expected: only the spec, plan, focused tests, and shared load-control implementation are present. + +- [ ] **Step 2: Push the branch** + +```bash +git push -u origin fix/android-buffer-sizing +``` + +- [ ] **Step 3: Open a draft pull request** + +Create a draft PR targeting `main`. Summarize the media-metadata-first rule, why observed adaptation is deferred, preserved issue #80 behavior, exact verification commands, independent review verdict, and short-timeout canary evidence or limitation. Do not merge. + +- [ ] **Step 4: Confirm hosted checks** + +Watch the PR checks to terminal state. If any hosted job fails, inspect the exact logs, reproduce systematically, correct at the lowest responsible boundary test-first, repush, and wait for green before reporting completion. diff --git a/docs/superpowers/plans/2026-07-27-android-tv-navigation-remediation.md b/docs/superpowers/plans/2026-07-27-android-tv-navigation-remediation.md new file mode 100644 index 000000000..f4d60c967 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-android-tv-navigation-remediation.md @@ -0,0 +1,976 @@ +# Android TV Navigation Remediation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore predictable Android TV navigation, unify For You saved-list presentation, and remove the duplicate, eager cold-start work that makes first Home traversal sluggish on production-sized servers. + +**Architecture:** A shared pure Home hydrator will consume inline aggregate sections and make at most four fallback requests for genuinely unresolved sections. Android TV will use explicit focus policies for recommendation entry and the Home-to-menu boundary, plus a small pure prefetch policy that permits neighbor work only after focus has settled. The For You selector will issue an explicit, repeatable inline-selection request to the existing For You screen; profile-menu saved-list routes remain standalone. + +**Tech Stack:** Kotlin Multiplatform, Kotlin coroutines and `kotlinx-coroutines-test`, Compose for TV focus APIs, Ktor `MockEngine`, Room-backed cache ports, Gradle Android unit/release tasks, ADB/gfxinfo for the final Shield smoke. + +## Global Constraints + +- Do not change server APIs, server configuration, recommendation ranking, Home row composition, authentication, playback, or database schema. +- Preserve Watchlist and Favorites behavior and the existing initial Watchlist focus. +- A repeated/held Up sequence stops on Home's first content row; only a fresh Up press enters the top menu. +- Watchlist and Favorites selected from the For You top-menu selector use the existing inline For You presentation. +- Watchlist and Favorites selected from the profile menu remain standalone utility pages. +- Preserve partial-refresh cache safety: an incomplete network result must not overwrite a complete cached Home. +- Keep item-detail screens network-first; cache-first semantics apply only to speculative marquee enrichment. +- Limit fallback Home hydration and speculative detail fan-out to four concurrent requests. +- Do not hide latency with a longer splash, input suppression, animation tuning, or server-side content caps. +- No production data mutation is permitted during verification. + +--- + +### Task 1: Share bounded, inline-first Home hydration + +**Files:** +- Create: `shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeSectionHydrator.kt` +- Create: `shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeSectionHydratorTest.kt` +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModel.kt:15-175` +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt:1-163` +- Test: `shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModelTest.kt` + +**Interfaces:** +- Consumes: `ResolvedSection`, `HomeSectionItemsResponse`, `ApiResult`, and the existing `SectionRepository.getHomeSectionItems(String)`. +- Produces: + +```kotlin +data class HomeSectionHydration( + val sections: List, + val fullyResolved: Boolean, +) + +suspend fun hydrateHomeSections( + sections: List, + maxConcurrency: Int = 4, + fetchItems: suspend (String) -> ApiResult, +): HomeSectionHydration +``` + +- [ ] **Step 1: Write the failing inline-section, response-shape, and concurrency tests** + +Create `HomeSectionHydratorTest` with literal fixtures and tests proving: + +```kotlin +@Test +fun inlineSectionsRequireNoFallbackRequests() = runTest { + var calls = 0 + val result = hydrateHomeSections(listOf(section("inline", total = 1, items = listOf(item("a"))))) { + calls += 1 + error("fallback must not run") + } + assertEquals(0, calls) + assertEquals(listOf("a"), result.sections.single().items.map { it.contentId }) + assertTrue(result.fullyResolved) +} + +@Test +fun topLevelFallbackItemsHydrateTheOriginalSection() = runTest { + val result = hydrateHomeSections(listOf(section("missing", total = 1))) { + ApiResult.Success(HomeSectionItemsResponse(items = listOf(item("b")))) + } + assertEquals("missing", result.sections.single().id) + assertEquals(listOf("b"), result.sections.single().items.map { it.contentId }) + assertTrue(result.fullyResolved) +} + +@Test +fun failedFallbackMarksSnapshotPartialAndOmitsEmptySection() = runTest { + val result = hydrateHomeSections(listOf(section("missing", total = 1))) { + ApiResult.NetworkError(IllegalStateException("offline")) + } + assertTrue(result.sections.isEmpty()) + assertFalse(result.fullyResolved) +} +``` + +Add a fourth test with twelve unresolved sections, two +`CompletableDeferred` gates, and atomic active/maximum counters. Hold the +first four requests at the gate, assert no fifth request starts, release them in +batches, and finally assert `maximum == 4`, all twelve IDs were fetched exactly +once, and output order matches input order. + +- [ ] **Step 2: Run the hydrator tests and verify RED** + +Run: + +```bash +./gradlew :shared:testDebugUnitTest --tests '*HomeSectionHydratorTest' --no-daemon +``` + +Expected: compilation failure because `hydrateHomeSections` and `HomeSectionHydration` do not exist. + +- [ ] **Step 3: Implement the pure hydrator with a four-request semaphore** + +Use `kotlinx.coroutines.sync.Semaphore` and `withPermit` inside `coroutineScope`. Preserve inline sections without invoking `fetchItems`; fetch only `items.isEmpty() && totalCount > 0`; resolve nested `section.items`, nested zero-total responses, and top-level `items` with the same precedence currently used by `HomeViewModel`. + +- [ ] **Step 4: Run the hydrator tests and verify GREEN** + +Run: + +```bash +./gradlew :shared:testDebugUnitTest --tests '*HomeSectionHydratorTest' --no-daemon +``` + +Expected: all `HomeSectionHydratorTest` cases pass. + +- [ ] **Step 5: Write caller regression tests before changing either caller** + +Extend `HomeViewModelTest` so a repository returning fully inline aggregate +sections records zero `getHomeSectionItems` calls while still updating UI and +cache. Create +`android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/startup/StartupHomeHydrationTest.kt` +with Android startup-shaped fixtures that assert the same zero-call contract. + +- [ ] **Step 6: Run the caller tests and verify RED** + +Run: + +```bash +./gradlew \ + :shared:testDebugUnitTest --tests '*HomeViewModelTest*inline*' \ + :android-shared:testDebugUnitTest --tests '*StartupHomeHydrationTest' \ + --max-workers=2 --no-daemon +``` + +Expected: the startup test reports one fallback request per inline section; +the shared ViewModel assertion protects the already-correct inline behavior. + +- [ ] **Step 7: Replace duplicated resolution code in both callers** + +In `HomeViewModel.fetchSections`, call: + +```kotlin +val hydration = hydrateHomeSections(sections) { sectionId -> + sectionRepository.getHomeSectionItems(sectionId) +} +val resolved = hydration.sections +val fullyResolved = hydration.fullyResolved +``` + +In `StartupWarmup.warmHome`, call the same function and cache/warm artwork only when `fullyResolved` is true and `sections` is nonempty. This removes the unconditional per-section N+1. + +- [ ] **Step 8: Run focused caller tests** + +Run: + +```bash +./gradlew \ + :shared:testDebugUnitTest --tests '*HomeSectionHydratorTest' --tests '*HomeViewModelTest' \ + :android-shared:testDebugUnitTest --tests '*StartupHomeHydrationTest' \ + --max-workers=2 --no-daemon +``` + +Expected: all focused tests pass with no fallback request for inline sections and a maximum of four for unresolved sections. + +- [ ] **Step 9: Commit Task 1** + +```bash +git add shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeSectionHydrator.kt \ + shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModel.kt \ + shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeSectionHydratorTest.kt \ + shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModelTest.kt \ + android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt \ + android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/startup/StartupHomeHydrationTest.kt +git commit -m "perf(android): hydrate inline home sections once" +``` + +### Task 2: Add cache-first speculative detail reads + +**Files:** +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/CatalogRepository.kt:110-125` +- Modify: `shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/CatalogRepositoryDetailCacheTest.kt` + +**Interfaces:** +- Consumes: existing `CatalogCachePort.getCachedItemDetail` and network-first `getItemDetail`. +- Produces: + +```kotlin +suspend fun getItemDetailForPrefetch(contentId: String): ApiResult +``` + +- [ ] **Step 1: Write failing cache-hit and cache-miss tests** + +Extend `CatalogRepositoryDetailCacheTest`: + +```kotlin +@Test +fun prefetchUsesCachedDetailWithoutNetwork() = runTest { + val cache = FakeCache(preset = ItemDetail(contentId = "c1", type = "movie", title = "Cached")) + val result = repoThatFailsOnNetwork(cache).getItemDetailForPrefetch("c1") + assertEquals("Cached", (result as ApiResult.Success).data.title) +} + +@Test +fun prefetchFetchesAndCachesWhenDetailIsAbsent() = runTest { + val cache = FakeCache() + val result = repo(HttpStatusCode.OK, """{"content_id":"c2","type":"movie","title":"Fresh"}""", cache) + .getItemDetailForPrefetch("c2") + assertEquals("Fresh", (result as ApiResult.Success).data.title) + assertEquals("c2", cache.cachedId) +} +``` + +- [ ] **Step 2: Run the repository tests and verify RED** + +Run: + +```bash +./gradlew :shared:testDebugUnitTest --tests '*CatalogRepositoryDetailCacheTest' --no-daemon +``` + +Expected: compilation failure because `getItemDetailForPrefetch` does not exist. + +- [ ] **Step 3: Implement the minimal cache-first method** + +```kotlin +suspend fun getItemDetailForPrefetch(contentId: String): ApiResult { + catalogCache.getCachedItemDetail(contentId)?.let { return ApiResult.Success(it) } + return getItemDetail(contentId) +} +``` + +Do not change `getItemDetail`. + +- [ ] **Step 4: Run the repository tests and verify GREEN** + +Run: + +```bash +./gradlew :shared:testDebugUnitTest --tests '*CatalogRepositoryDetailCacheTest' --no-daemon +``` + +Expected: every cache test passes; the existing network-first tests remain unchanged. + +- [ ] **Step 5: Commit Task 2** + +```bash +git add shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/CatalogRepository.kt \ + shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/CatalogRepositoryDetailCacheTest.kt +git commit -m "perf(tv): make marquee prefetch cache first" +``` + +### Task 3: Make Skyline prefetch settled, bounded, and demand-driven + +**Files:** +- Create: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePrefetchPolicy.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePrefetchPolicyTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineSectionFeed.kt:95-341` +- Test: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeEnrichmentTest.kt` + +**Interfaces:** +- Consumes: the raw focused content ID, the committed/rested marquee content ID, the focused row, and radius two. +- Produces: + +```kotlin +internal fun settledPrefetchItems( + items: List, + rawFocusedContentId: String?, + settledContentId: String?, + radius: Int = 2, +): List +``` + +The function returns an empty list unless raw and settled identities match. When they match, it returns at most two neighbors on each side, excluding the focused item. + +- [ ] **Step 1: Write the failing policy tests** + +Create literal five-card fixtures and assert: + +```kotlin +@Test +fun rapidFocusBeforeMarqueeSettlementStartsNoNeighborWork() { + assertEquals( + emptyList(), + settledPrefetchItems(items, rawFocusedContentId = "d", settledContentId = "b"), + ) +} + +@Test +fun settledFocusReturnsOnlyTwoNeighborsPerSide() { + assertEquals( + listOf("a", "b", "d", "e"), + settledPrefetchItems(items, rawFocusedContentId = "c", settledContentId = "c") + .map { it.contentId }, + ) +} +``` + +Also cover first-card and missing-ID boundaries. + +- [ ] **Step 2: Run the policy tests and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvSkylinePrefetchPolicyTest' --no-daemon +``` + +Expected: compilation failure because `settledPrefetchItems` does not exist. + +- [ ] **Step 3: Implement the pure settled-focus policy** + +Use `indexOfFirst`, a clamped inclusive range, and `takeIf` identity equality. Return values in row order and exclude the focused index. + +- [ ] **Step 4: Run the policy tests and verify GREEN** + +Run the same command and confirm all boundary cases pass. + +- [ ] **Step 5: Remove unconditional page-entry preload effects** + +Delete only the two `LaunchedEffect(rows)`/`LaunchedEffect(rows, fetchDetail)` blocks that preload hero artwork and full details for two rows × eight items. Remove `HeroPreloadRowCount` and `HeroPreloadItemsPerRow`. Retain the initial aggregate-data marquee seed and startup artwork plan. + +- [ ] **Step 6: Wire cache-first settled neighbor work** + +Change the injected detail lambda to call `catalogRepository.getItemDetailForPrefetch`. Replace the raw-index neighbor calculation with `settledPrefetchItems`. Key the effect on `rows`, `focusedContentId`, `marquee.content?.contentId`, and the fetcher. A raw focus move cancels the old job immediately; the identity mismatch starts no new work until the marquee commits after its existing 150 ms rest. + +Keep the existing maximum four neighbors, request claim, stale-result guard, artwork sizing, and composition-cancellation ownership. + +- [ ] **Step 7: Add an integration-level request-budget test** + +Extend `TvFocusMarqueeEnrichmentTest` with a coroutine test that sends focus identities `a`, `b`, `c` within less than 150 ms and proves only the settled `c` policy window is returned. Mutate the equality guard locally to verify the test fails by returning the `b` window, then restore the implementation. + +- [ ] **Step 8: Run the Skyline and marquee suite** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvSkylinePrefetchPolicyTest' \ + --tests '*TvFocusMarqueeModelTest' \ + --tests '*TvFocusMarqueeEnrichmentTest' \ + --max-workers=2 --no-daemon +``` + +Expected: all focused tests pass. + +- [ ] **Step 9: Commit Task 3** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePrefetchPolicy.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineSectionFeed.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePrefetchPolicyTest.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeEnrichmentTest.kt +git commit -m "perf(tv): defer skyline work until focus settles" +``` + +### Task 4: Bridge For You filters into recommendation rows + +**Files:** +- Create: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridge.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridgeTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt:64-289` + +**Interfaces:** +- Consumes: row-container/card focus request lambdas and a one-frame suspension. +- Produces: + +```kotlin +internal suspend fun requestRecommendationRowFocus( + requestRowContainer: () -> Boolean, + awaitFrame: suspend () -> Unit, + requestFirstCard: () -> Boolean, +): Boolean + +internal fun shouldBridgeRecommendationsDown( + showingRecommendations: Boolean, + hasVisibleRecommendations: Boolean, +): Boolean +``` + +The function returns false only when the row container cannot accept focus. After a successful row hop it waits one frame and targets the first card. + +- [ ] **Step 1: Write the failing focus-bridge tests** + +Create tests with a literal event list: + +```kotlin +@Test +fun handoffCrossesRowRestorerBeforeTargetingFirstCard() = runTest { + val events = mutableListOf() + val handled = requestRecommendationRowFocus( + requestRowContainer = { events += "row"; true }, + awaitFrame = { events += "frame" }, + requestFirstCard = { events += "card"; true }, + ) + assertTrue(handled) + assertEquals(listOf("row", "frame", "card"), events) +} + +@Test +fun rejectedRowHopDoesNotTargetCard() = runTest { + val events = mutableListOf() + val handled = requestRecommendationRowFocus( + requestRowContainer = { events += "row"; false }, + awaitFrame = { events += "frame" }, + requestFirstCard = { events += "card"; true }, + ) + assertFalse(handled) + assertEquals(listOf("row"), events) +} +``` + +- [ ] **Step 2: Add the failing visibility-policy tests** + +Assert that `shouldBridgeRecommendationsDown` returns true only when For You is +selected and at least one recommendation row is visible. Assert false for +Watchlist, Favorites, loading, error, and empty For You states. + +- [ ] **Step 3: Run the bridge tests and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvRecommendationsFocusBridgeTest' --no-daemon +``` + +Expected: compilation failure because the bridge does not exist. + +- [ ] **Step 4: Implement the minimal bridge** + +Implement exactly the ordered row/frame/card sequence and the two-boolean +visibility policy. Do not retry or add delays; `TvMediaRow` owns the row +`focusRestorer` contract. + +- [ ] **Step 5: Run the bridge tests and verify GREEN** + +Run the same command and confirm both ordering and rejected-hop behavior pass. + +- [ ] **Step 6: Wire stable requesters into the screen** + +Add stable requesters for For You, Watchlist, Favorites, the first recommendation row container, and its first card. Render recommendation rows with indexed items so only index zero receives: + +```kotlin +firstItemFocusRequester = recommendationFirstCardFocusRequester +rowContainerFocusRequester = recommendationFirstRowContainerFocusRequester +onDirectionUp = { + selectedFilterRequester.requestFocus() +} +``` + +Each filter pill's `onDirectionDown` consults +`shouldBridgeRecommendationsDown`; Watchlist and Favorites continue to use +their existing geometric grid navigation when selected. Keep initial entry on +Watchlist. The first For You row's `onDirectionUp` requests the For You pill. + +- [ ] **Step 7: Run the complete focused TV suite** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvRecommendationsFocusBridgeTest' \ + --tests '*TvSkylinePrefetchPolicyTest' \ + --tests '*TvFocusMarqueeModelTest' \ + --tests '*TvFocusMarqueeEnrichmentTest' \ + --max-workers=2 --no-daemon +``` + +Expected: every focused test passes. + +- [ ] **Step 8: Commit Task 4** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridge.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridgeTest.kt +git commit -m "fix(tv): enter for-you rows from filter controls" +``` + +### Task 5: Make the Home-to-menu boundary deliberate + +**Files:** +- Create: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineUpNavigation.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineUpNavigationTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineSectionFeed.kt:277-318` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/home/TvHomeScreen.kt:38-216` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt:369-401,789-810` + +**Interfaces:** +- Consumes: `focusedRowIndex`, `rows.indices`, Android `KeyEvent.nativeKeyEvent.repeatCount`, and the existing shell-owned content-to-menu handoff. +- Produces: + +```kotlin +internal enum class TvSkylineUpAction { + EnterMenu, + StayInContent, + TryPreviousRow, +} + +internal fun tvSkylineUpAction( + currentRow: Int, + rowCount: Int, + isRepeat: Boolean, + relocationInFlight: Boolean, +): TvSkylineUpAction +``` + +The content fallback callback becomes `(isRepeat: Boolean) -> Boolean`. `true` +means the feed consumed the event; `false` means the shell may focus the top +menu. + +- [ ] **Step 1: Write the failing pure focus-boundary tests** + +Create `TvSkylineUpNavigationTest`: + +```kotlin +@Test +fun heldUpStopsOnFirstContentRow() { + assertEquals( + TvSkylineUpAction.StayInContent, + tvSkylineUpAction(currentRow = 0, rowCount = 6, isRepeat = true, relocationInFlight = false), + ) +} + +@Test +fun freshUpFromFirstContentRowMayEnterMenu() { + assertEquals( + TvSkylineUpAction.EnterMenu, + tvSkylineUpAction(currentRow = 0, rowCount = 6, isRepeat = false, relocationInFlight = false), + ) +} + +@Test +fun repeatedInputDuringOffscreenRelocationIsConsumed() { + assertEquals( + TvSkylineUpAction.StayInContent, + tvSkylineUpAction(currentRow = 4, rowCount = 6, isRepeat = true, relocationInFlight = true), + ) +} + +@Test +fun ordinaryUpWithinRowsTriesExactlyOnePreviousRow() { + assertEquals( + TvSkylineUpAction.TryPreviousRow, + tvSkylineUpAction(currentRow = 4, rowCount = 6, isRepeat = false, relocationInFlight = false), + ) +} +``` + +- [ ] **Step 2: Run the boundary tests and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvSkylineUpNavigationTest' \ + --max-workers=2 --no-daemon +``` + +Expected: compilation fails because `TvSkylineUpAction` and +`tvSkylineUpAction` do not exist. + +- [ ] **Step 3: Implement the pure policy** + +Create `TvSkylineUpNavigation.kt`: + +```kotlin +package org.prairieserver.prairie.tv.ui.components + +internal enum class TvSkylineUpAction { + EnterMenu, + StayInContent, + TryPreviousRow, +} + +internal fun tvSkylineUpAction( + currentRow: Int, + rowCount: Int, + isRepeat: Boolean, + relocationInFlight: Boolean, +): TvSkylineUpAction = when { + relocationInFlight -> TvSkylineUpAction.StayInContent + currentRow !in 0 until rowCount -> + if (isRepeat) TvSkylineUpAction.StayInContent else TvSkylineUpAction.EnterMenu + currentRow == 0 -> + if (isRepeat) TvSkylineUpAction.StayInContent else TvSkylineUpAction.EnterMenu + else -> TvSkylineUpAction.TryPreviousRow +} +``` + +- [ ] **Step 4: Run the pure tests and verify GREEN** + +Run the Step 2 command. Expected: all four tests pass. + +- [ ] **Step 5: Wire repeat identity and serialized relocation** + +Change `onContentUpFallbackChanged` through `TvHomeScreen`, +`TvHomeContent`, and `TvSkylineSectionFeed` to carry +`((isRepeat: Boolean) -> Boolean)`. + +In `TvSkylineSectionFeed`, remember `rowRelocationInFlight`. For +`TryPreviousRow`, first call `focusManager.moveFocus(FocusDirection.Up)`. If +that fails, set `rowRelocationInFlight = true`, launch exactly one +`animateScrollToItem(currentRow - 1)` job, await one frame, attempt the focus +move, and clear the flag in `finally`. `StayInContent` returns `true`; +`EnterMenu` returns `false`. + +In `TvMainShell`, pass: + +```kotlin +val isRepeat = ev.nativeKeyEvent.repeatCount > 0 +val contentHandledUp = contentUpFallback?.invoke(isRepeat) +``` + +Keep the shell's existing `focusState.requestMenuFocus()` behavior only when +the active feed returns `false`. + +- [ ] **Step 6: Run the boundary test and TV compilation** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvSkylineUpNavigationTest' \ + :androidTvApp:compileDebugKotlin \ + --max-workers=2 --no-daemon +``` + +Expected: tests and compilation pass. + +- [ ] **Step 7: Commit Task 5** + +```bash +git add \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineUpNavigation.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineSectionFeed.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/home/TvHomeScreen.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineUpNavigationTest.kt +git commit -m "fix(tv): stop held up at the first home row" +``` + +### Task 6: Route For You saved lists through one presentation + +**Files:** +- Create: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequest.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequestTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt:67-117` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt:971-976,1224-1242,1290-1307` + +**Interfaces:** +- Consumes: the existing `TvMainRoute.ForYou`, `TvRecommendationsScreen`, + `TvWatchlistInline`, `TvFavoritesInline`, and standalone profile-menu routes. +- Produces: + +```kotlin +internal enum class SavedListSelection { + Watchlist, + Favorites, +} + +internal data class TvForYouEntryRequest( + val sequence: Int = 0, + val selection: SavedListSelection? = null, +) { + fun next(selection: SavedListSelection?): TvForYouEntryRequest = + TvForYouEntryRequest(sequence = sequence + 1, selection = selection) +} + +internal data class AppliedForYouSelection( + val selection: SavedListSelection?, + val lastAppliedSequence: Int, +) + +internal fun applyForYouEntryRequest( + currentSelection: SavedListSelection?, + lastAppliedSequence: Int, + request: TvForYouEntryRequest, +): AppliedForYouSelection +``` + +`TvRecommendationsScreen` accepts +`entryRequest: TvForYouEntryRequest = TvForYouEntryRequest()` and applies its +selection only when `entryRequest.sequence` changes. + +- [ ] **Step 1: Write the failing request-state tests** + +Create `TvForYouEntryRequestTest`: + +```kotlin +@Test +fun repeatedSelectionStillCreatesANewRequest() { + val first = TvForYouEntryRequest().next(SavedListSelection.Watchlist) + val second = first.next(SavedListSelection.Watchlist) + + assertEquals(1, first.sequence) + assertEquals(2, second.sequence) + assertEquals(SavedListSelection.Watchlist, second.selection) +} + +@Test +fun recommendationsRequestClearsSavedListSelection() { + val request = TvForYouEntryRequest( + sequence = 4, + selection = SavedListSelection.Favorites, + ).next(null) + + assertEquals(5, request.sequence) + assertNull(request.selection) +} + +@Test +fun unrelatedRecompositionDoesNotOverrideInPageSelection() { + val applied = applyForYouEntryRequest( + currentSelection = SavedListSelection.Favorites, + lastAppliedSequence = 3, + request = TvForYouEntryRequest( + sequence = 3, + selection = SavedListSelection.Watchlist, + ), + ) + + assertEquals(SavedListSelection.Favorites, applied.selection) + assertEquals(3, applied.lastAppliedSequence) +} + +@Test +fun newerRequestAppliesRequestedInlineSelection() { + val applied = applyForYouEntryRequest( + currentSelection = null, + lastAppliedSequence = 3, + request = TvForYouEntryRequest( + sequence = 4, + selection = SavedListSelection.Watchlist, + ), + ) + + assertEquals(SavedListSelection.Watchlist, applied.selection) + assertEquals(4, applied.lastAppliedSequence) +} +``` + +- [ ] **Step 2: Run the request-state tests and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvForYouEntryRequestTest' \ + --max-workers=2 --no-daemon +``` + +Expected: compilation fails because the request and applied-selection types do +not exist. + +- [ ] **Step 3: Implement the repeatable entry request** + +Create `TvForYouEntryRequest.kt` with the exact interfaces above. The apply +function returns the current selection unchanged when +`request.sequence <= lastAppliedSequence`; otherwise it returns the request's +selection and sequence. Move +`SavedListSelection` out of `TvRecommendationsScreen.kt` into that file. + +Add the screen parameter: + +```kotlin +entryRequest: TvForYouEntryRequest = TvForYouEntryRequest(), +``` + +Initialize selection and the last applied sequence with `remember`, then add: + +```kotlin +LaunchedEffect(entryRequest.sequence) { + val applied = applyForYouEntryRequest( + currentSelection = savedListSelection, + lastAppliedSequence = lastAppliedEntrySequence, + request = entryRequest, + ) + savedListSelection = applied.selection + lastAppliedEntrySequence = applied.lastAppliedSequence +} +``` + +This permits the same top-menu choice to be selected repeatedly and does not +overwrite in-page pill changes during unrelated recompositions. + +- [ ] **Step 4: Wire only the For You selector to inline requests** + +In `TvMainShell`, remember: + +```kotlin +var forYouEntryRequest by remember { mutableStateOf(TvForYouEntryRequest()) } +val openForYou: (SavedListSelection?) -> Unit = { selection -> + forYouEntryRequest = forYouEntryRequest.next(selection) + focusState.closePanel(false) + navigateToSecondary(TvMainRoute.ForYou.route) + moveFocusToContent(TvMainRoute.ForYou.route) +} +``` + +Pass `entryRequest = forYouEntryRequest` to `TvRecommendationsScreen`. Wire +the three `TvForYouSelector` callbacks to: + +```kotlin +onWatchlist = { openForYou(SavedListSelection.Watchlist) } +onFavorites = { openForYou(SavedListSelection.Favorites) } +onRecommendations = { openForYou(null) } +``` + +Do not change `TvProfileDropdown` callbacks: they must continue navigating to +`TvMainRoute.Watchlist` and `TvMainRoute.Favorites`. + +- [ ] **Step 5: Run focused routing and existing focus tests** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvForYouEntryRequestTest' \ + --tests '*TvRecommendationsFocusBridgeTest' \ + --max-workers=2 --no-daemon +``` + +Expected: all tests pass. + +- [ ] **Step 6: Manually inspect the two call-site groups** + +Run: + +```bash +git diff -- \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt +``` + +Confirm the `TvForYouSelector` callbacks all invoke `openForYou`, while the +`TvProfileDropdown` Watchlist/Favorites callbacks still navigate directly to +their standalone routes. + +- [ ] **Step 7: Commit Task 6** + +```bash +git add \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequest.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequestTest.kt +git commit -m "fix(tv): unify for-you saved-list routes" +``` + +### Task 7: Full verification, production-shaped smoke, and executive summary + +**Files:** +- Create: `docs/reviews/2026-07-27-android-tv-navigation-remediation-executive-summary.md` +- Modify only if verification exposes a feature regression: files already listed in Tasks 1-4. + +**Interfaces:** +- Consumes: all prior task commits and an explicitly approved authenticated TV + test target. +- Produces: a concise executive summary containing impact, causes, remedy, quantified before/after evidence, rollout risk, and rollback boundary. + +- [ ] **Step 1: Run supply-chain and complete relevant unit gates** + +Run: + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +./gradlew :shared:testDebugUnitTest \ + :android-shared:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --max-workers=2 --no-daemon +``` + +Expected: exit zero with no failed test task. + +- [ ] **Step 2: Compile debug and minified TV release** + +Run: + +```bash +./gradlew \ + :androidTvApp:assembleDebug \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --max-workers=2 --no-daemon +``` + +Expected: both tasks exit zero. + +- [ ] **Step 3: Perform the For You device smoke** + +Install only on the explicitly selected Shield if the candidate signer is compatible with the installed package; otherwise use the dedicated TV emulator. Preserve app data with: + +```bash +TV_TEST_SERIAL="" +adb -s "$TV_TEST_SERIAL" install -r \ + androidTvApp/build/outputs/apk/release/androidTvApp-universal-release.apk +``` + +Verify: + +1. Enter For You from the top menu. +2. Initial focus remains Watchlist. +3. Select For You and press Down; focus reaches the first visible recommendation card. +4. Press Down/Up repeatedly; focus is not trapped and Up returns to For You. +5. Select Watchlist and Favorites from the in-page pills; Down enters their inline grids. +6. Select Watchlist and Favorites from the top For You selector; each opens the + same inline For You presentation with the matching pill selected. +7. Select Watchlist and Favorites from the profile menu; each retains its + standalone utility-page presentation. +8. From several Home rows down, hold Up until the first row is reached; the + repeated sequence stays in content, and a released-then-fresh Up enters the + selected top-menu item. + +- [ ] **Step 4: Repeat the cold/warm performance protocol** + +Against `lib.strm.cafe`, clear only process state with `am force-stop`, reset `dumpsys gfxinfo`, clear logcat, launch, and run the same fixed sequences used during diagnosis: + +- horizontal: five Right then five Left at 250 ms; +- vertical: three Down then three Up at 500 ms; +- repeat each immediately without process restart. + +Record total/janky frames, p50/p90/p95/p99, sanitized HTTP completion count, GC count, crash, and ANR status. Do not capture credentials or request headers. + +- [ ] **Step 5: Write the executive summary** + +Create the summary with these exact sections: + +- `Decision`: what was fixed and why it is safe to ship. +- `Customer impact`: For You accessibility and first-traversal responsiveness. +- `Verified causes`: missing focus bridge, duplicate Home hydration, eager detail + fan-out, an unguarded repeated-Up boundary, and two For You saved-list routes. +- `Change`: inline-first bounded hydration, cache-first rested enrichment, + deliberate focus handoff, serialized row relocation, and unified For You + selector presentation. +- `Evidence`: before/after cold and warm measurements and test/build counts. +- `Risk and rollback`: Android TV focus/prefetch scope, no schema/server change, revert commits independently. + +- [ ] **Step 6: Review diff and obtain independent review** + +Run: + +```bash +git diff --check origin/main...HEAD +git status --short +git log --oneline origin/main..HEAD +``` + +Request focused review of correctness, coroutine cancellation, focus-restorer ordering, cache semantics, and whether tests detect realistic regressions. Fix every Critical or Important finding test-first and rerun the smallest affected gate. + +- [ ] **Step 7: Commit the verified executive summary** + +```bash +git add docs/reviews/2026-07-27-android-tv-navigation-remediation-executive-summary.md +git commit -m "docs(tv): summarize navigation remediation" +``` + +- [ ] **Step 8: Run the final fresh gate** + +Run: + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +./gradlew :shared:testDebugUnitTest \ + :android-shared:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --max-workers=2 --no-daemon +``` + +Expected: exit zero. Confirm `git status --short` is empty before invoking `superpowers:finishing-a-development-branch`. diff --git a/docs/superpowers/plans/2026-07-27-pr108-slice-b-auth-epub.md b/docs/superpowers/plans/2026-07-27-pr108-slice-b-auth-epub.md index 66b497978..a8b8bb02f 100644 --- a/docs/superpowers/plans/2026-07-27-pr108-slice-b-auth-epub.md +++ b/docs/superpowers/plans/2026-07-27-pr108-slice-b-auth-epub.md @@ -18,7 +18,7 @@ the reviewed slice-A head as one security-focused, compiling vertical slice. - `eda4a4a2` — keep hardened EPUB paths compatible with Android API 24 - `1fecf9b1` — centralize authenticated HTTP origin comparison - `cbf398fa` — reject ambiguous HTTP authorities -- `5d5f0562` — keep credentials on the configured Silo origin +- `5d5f0562` — keep credentials on the configured Prairie origin - `e18d092e` — reject authentication scopes whose credentials were replaced - `c07d9f9f` — require explicit consent before cleartext login diff --git a/docs/superpowers/plans/2026-07-27-pr108-slice-f-watch-together.md b/docs/superpowers/plans/2026-07-27-pr108-slice-f-watch-together.md index 3d50c4b82..7ef631913 100644 --- a/docs/superpowers/plans/2026-07-27-pr108-slice-f-watch-together.md +++ b/docs/superpowers/plans/2026-07-27-pr108-slice-f-watch-together.md @@ -196,7 +196,7 @@ OkHttp/MockWebServer, Koin, Android Compose, Gradle, ADB/emulator tooling. **Environment:** - Use two concurrent dedicated AVDs, preferring `Silo_Phone` and `Silo_TV`. - Use distinct app data and user/profile identities. -- Start an appropriate local/configured Silo test backend without modifying +- Start an appropriate local/configured Prairie test backend without modifying production data. **Flow:** diff --git a/docs/superpowers/plans/2026-07-27-watch-together-user-menu-entry.md b/docs/superpowers/plans/2026-07-27-watch-together-user-menu-entry.md new file mode 100644 index 000000000..e914eef87 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-watch-together-user-menu-entry.md @@ -0,0 +1,2174 @@ +# Watch Together User-Menu Entry Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a top-level Watch Together entry to the authenticated profile menu on Android phone and Android TV, supporting empty vote-room hosting, join by code, and same-process room resume while preserving the existing title-detail and room-authority behavior. + +**Architecture:** Add one narrow shared entry gateway and pure destination/resume policy over the existing `WatchTogetherRepository`; the current phone and TV entry ViewModels remain the platform orchestration points. Phone renders a Material modal sheet and TV renders a focusable popup, both routing into the existing lobby/player and `RoomSession`; no repository, socket, protocol, server endpoint, or persistent room store is added. + +**Tech Stack:** Kotlin 2.1, Kotlin Multiplatform shared module, coroutines and `StateFlow`, Koin, Jetpack Compose Material 3, Compose for TV Material 3, Navigation Compose, JUnit/kotlin-test, Robolectric for phone route tests, Gradle, ADB. + +## Global Constraints + +- Android phone and Android TV only; do not change Prairie Server, Apple clients, or production proxy configuration. +- Add **Watch Together** to the authenticated user/profile menu immediately after **Requests** when Requests is present; otherwise keep it in the same content/action group immediately before the settings/account divider. +- The menu entry is a transient phone sheet or TV popup, not a persistent Watch Together home. +- **Host a room** creates exactly one empty vote room with `selection_mode = "vote"` and must not call `setSelection`. +- **Join by code** must continue to use the existing repository, validation, error mapping, and lobby/player destination rules. +- **Resume current room** is visible only for a valid, non-terminal room in the same running process, server, and authenticated profile; do not add persistence or room discovery. +- The room owner remains a full participant who may suggest, vote, apply the existing host override to any room-owned suggestion, and close the room for everyone. +- Keep current server host semantics: no automatic host transfer, ownership election, original-host reclaim, or timeout changes. +- After host logout, profile/server switch, process death, or unrecovered disconnect, the server may close the room after its existing host-disconnect timeout; the clients do not extend or replace that policy. +- Navigation and backgrounding preserve the live process-scoped room; logout, profile/server switch, explicit Leave, terminal room closure, and process death clear local state through existing ownership boundaries. +- Reuse `WatchTogetherRepository`, `RoomSession`, existing lobby/player routes, websocket/reconnect behavior, voting, auth scope, cleartext consent, and error handling. +- Preserve the existing title-detail Watch Together entry and its preselected-title Host behavior. +- Room credentials remain repository-private and must not enter UI state, route parameters, logs, or tests. +- No changes to `WatchTogetherApi`, Watch Together wire models, websocket frames, or server/proxy configuration. + +--- + +## Current `origin/main` map + +The plan is based on `origin/main` `e0917cbe8cc1b021f184954e1e7cc977c06f628e`. + +| Responsibility | Current file and seam | +|---|---| +| Shared room owner | `shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepository.kt:71-138,194-378,725-741` | +| Process/session owner | `shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomSession.kt:20-104` | +| Identity teardown | `shared/src/commonMain/kotlin/org/prairieserver/prairie/network/IdentityTransitionBarrier.kt` and `RoomSession` transition gate | +| Shared DI | `shared/src/commonMain/kotlin/org/prairieserver/prairie/di/RepositoryModule.kt:96-123` | +| Phone entry controller | `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModel.kt:21-125` | +| Phone title-detail sheet | `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntrySheet.kt:37-138` | +| Phone profile menus | `MainAppTopBar.kt:52-181`, `HomeScreen.kt:84-100,291-305,444-525`, `LibrariesScreen.kt:617-630,1168-1183,1328-1408` | +| Phone shell | `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt:120-451` | +| Phone lobby | `WatchTogetherLobbyScreen.kt:45-203`, `WatchTogetherLobbyViewModel.kt:35-88` | +| TV entry controller | `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModel.kt:18-113` | +| TV title-detail dialog | `TvWatchTogetherEntryDialog.kt:31-113` and `TvItemDetailScreen.kt:1048-1104` | +| TV profile menu/shell | `TvMainShell.kt:166-180,607-610,1270-1313,1478-1563` | +| TV root navigation | `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt:480-545,630-712,875-900` | +| TV lobby | `TvWatchTogetherLobbyScreen.kt:76-175,250-330,523-558`, `TvWatchTogetherLobbyViewModel.kt:17-83` | +| Existing behavior tests | `WatchTogetherRepositoryTest.kt`, `RoomSessionTest.kt`, `WatchTogetherEntryDestinationTest.kt`, `TvWatchTogetherSurfaceSourceTest.kt`, `TvShellFocusStateTest.kt` | + +## Task 1: Shared entry policy and narrow repository gateway + +**Files:** +- Create: `shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicy.kt` +- Create: `shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryGateway.kt` +- Create: `shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicyTest.kt` +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepository.kt:71-100,181-284` +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/di/RepositoryModule.kt:96-123` + +**Interfaces:** +- Produces: `enum class WatchTogetherEntryTarget { Lobby, Player }` +- Produces: `fun watchTogetherEntryTarget(room: RoomSnapshot): WatchTogetherEntryTarget` +- Produces: `fun resumableWatchTogetherRoom(room: RoomSnapshot?): RoomSnapshot?` +- Produces: `interface WatchTogetherEntryGateway` with `roomSnapshot`, `createRoom`, `joinRoom`, and `setSelection` +- Preserves: the concrete singleton `WatchTogetherRepository`; the gateway is only a testable view of its existing methods. + +- [ ] **Step 1: Write the failing shared policy tests** + +```kotlin +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class WatchTogetherEntryPolicyTest { + @Test + fun selectedGuestRoutesToPlayer() { + val room = RoomSnapshot( + roomId = "room-1", + selectedContentId = "movie-1", + selfRole = MemberRole.Guest, + memberCount = 2, + ) + assertEquals(WatchTogetherEntryTarget.Player, watchTogetherEntryTarget(room)) + } + + @Test + fun emptyRoomAndSoloHostRouteToLobby() { + assertEquals( + WatchTogetherEntryTarget.Lobby, + watchTogetherEntryTarget(RoomSnapshot(roomId = "room-1")), + ) + assertEquals( + WatchTogetherEntryTarget.Lobby, + watchTogetherEntryTarget( + RoomSnapshot( + roomId = "room-1", + selectedContentId = "movie-1", + selfRole = MemberRole.Host, + memberCount = 1, + ), + ), + ) + } + + @Test + fun onlyNonTerminalNonBlankRoomIsResumable() { + assertNull(resumableWatchTogetherRoom(null)) + assertNull(resumableWatchTogetherRoom(RoomSnapshot(roomId = ""))) + assertNull( + resumableWatchTogetherRoom( + RoomSnapshot(roomId = "room-1", phase = RoomPhase.Ended), + ), + ) + assertEquals( + "room-1", + resumableWatchTogetherRoom( + RoomSnapshot(roomId = "room-1", phase = RoomPhase.Lobby), + )?.roomId, + ) + } +} +``` + +- [ ] **Step 2: Run the policy test and verify RED** + +Run: + +```bash +./gradlew :shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.watchtogether.WatchTogetherEntryPolicyTest' +``` + +Expected: FAIL to compile because `WatchTogetherEntryTarget`, `watchTogetherEntryTarget`, and `resumableWatchTogetherRoom` do not exist. + +- [ ] **Step 3: Implement the pure policy** + +```kotlin +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot + +enum class WatchTogetherEntryTarget { + Lobby, + Player, +} + +fun watchTogetherEntryTarget(room: RoomSnapshot): WatchTogetherEntryTarget = + if ( + !room.selectedContentId.isNullOrBlank() && + !(room.selfRole == MemberRole.Host && room.memberCount <= 1) + ) { + WatchTogetherEntryTarget.Player + } else { + WatchTogetherEntryTarget.Lobby + } + +fun resumableWatchTogetherRoom(room: RoomSnapshot?): RoomSnapshot? = + room?.takeIf { it.roomId.isNotBlank() && it.phase != RoomPhase.Ended } +``` + +- [ ] **Step 4: Add the narrow gateway and bind the existing singleton** + +```kotlin +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest +import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.RoomResponse +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.network.ApiResult +import kotlinx.coroutines.flow.StateFlow + +interface WatchTogetherEntryGateway { + val roomSnapshot: StateFlow + suspend fun createRoom(request: CreateRoomRequest): ApiResult + suspend fun joinRoom(request: JoinRoomRequest): ApiResult + suspend fun setSelection(request: SetSelectionRequest): ApiResult +} +``` + +Change the repository declaration and existing members to implement the interface: + +```kotlin +) : RoomSessionRepository, WatchTogetherEntryGateway { + override val roomSnapshot: StateFlow = _roomSnapshot.asStateFlow() +} +``` + +Add the `override` modifier to the existing `createRoom`, `joinRoom`, and +`setSelection` declarations. Do not alter any statement inside those three +existing method bodies; verify their body diff is empty. + +Bind the same singleton in `RepositoryModule.kt` immediately after its concrete +registration: + +```kotlin +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway + +single { get() } +``` + +- [ ] **Step 5: Run shared tests and compile both clients** + +Run: + +```bash +./gradlew \ + :shared:testDebugUnitTest \ + :androidApp:compileDebugKotlinAndroid \ + :androidTvApp:compileDebugKotlinAndroid \ + --max-workers=2 +``` + +Expected: PASS; no new network or model source is compiled. + +- [ ] **Step 6: Commit the shared boundary** + +```bash +git add \ + shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicy.kt \ + shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryGateway.kt \ + shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicyTest.kt \ + shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepository.kt \ + shared/src/commonMain/kotlin/org/prairieserver/prairie/di/RepositoryModule.kt +git commit -m "refactor: define Watch Together entry boundary" +``` + +## Task 2: Phone entry controller behavior + +**Files:** +- Create: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModelTest.kt` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModel.kt:21-125` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntrySheet.kt:92-112` + +**Interfaces:** +- Consumes: `WatchTogetherEntryGateway`, `watchTogetherEntryTarget`, and `resumableWatchTogetherRoom` +- Produces: `val currentRoom: StateFlow` +- Produces: `fun hostEmptyVoteRoom()` +- Produces: `fun resumeCurrentRoom()` +- Preserves: `fun host(contentId: String, fileId: Int?, selectionMode: RoomSelectionMode)` and `fun joinByCode(code: String)` + +- [ ] **Step 1: Write the phone ViewModel fake and RED tests** + +```kotlin +package org.prairieserver.prairie.android.ui.screens.watchtogether + +import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest +import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomResponse +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +class WatchTogetherEntryViewModelTest { + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun emptyHostCreatesVoteRoomWithoutSelection() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = WatchTogetherEntryViewModel(gateway) + + viewModel.hostEmptyVoteRoom() + + assertEquals(listOf(CreateRoomRequest(RoomSelectionMode.Vote.wire)), gateway.createRequests) + assertEquals(emptyList(), gateway.selectionRequests) + assertEquals("watch_together/room-1", viewModel.uiState.value.destination) + } + + @Test + fun resumeUsesCurrentRoomWithoutCreateOrJoin() = runTest(dispatcher) { + val room = RoomSnapshot(roomId = "room-1", phase = RoomPhase.Lobby) + val gateway = FakeGateway(room) + val viewModel = WatchTogetherEntryViewModel(gateway) + + viewModel.resumeCurrentRoom() + + assertEquals("watch_together/room-1", viewModel.uiState.value.destination) + assertEquals(emptyList(), gateway.createRequests) + assertEquals(emptyList(), gateway.joinRequests) + } + + @Test + fun identityClearRemovesResumeState() = runTest(dispatcher) { + val gateway = FakeGateway(RoomSnapshot(roomId = "room-1", phase = RoomPhase.Lobby)) + val viewModel = WatchTogetherEntryViewModel(gateway) + + gateway.roomSnapshot.value = null + + assertNull(viewModel.currentRoom.value) + } + + @Test + fun titleHostStillSetsTheSelectedTitle() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = WatchTogetherEntryViewModel(gateway) + + viewModel.host(contentId = "movie-1", fileId = 7) + + assertEquals( + listOf(SetSelectionRequest(contentId = "movie-1", fileId = 7)), + gateway.selectionRequests, + ) + } + + @Test + fun joinByCodeTrimsAndUsesExistingErrorMapping() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = WatchTogetherEntryViewModel(gateway) + + viewModel.joinByCode(" ABCD1234 ") + assertEquals(listOf(JoinRoomRequest(code = "ABCD1234")), gateway.joinRequests) + + viewModel.consumeDestination() + gateway.joinResult = ApiResult.NetworkError(IllegalStateException("offline")) + viewModel.joinByCode("EFGH5678") + assertEquals("Network error. Check your connection.", viewModel.uiState.value.error) + } + + private class FakeGateway( + room: RoomSnapshot? = null, + ) : WatchTogetherEntryGateway { + override val roomSnapshot = MutableStateFlow(room) + val createRequests = mutableListOf() + val joinRequests = mutableListOf() + val selectionRequests = mutableListOf() + var joinResult: ApiResult? = null + var nextRoom = RoomSnapshot( + roomId = "room-1", + phase = RoomPhase.Lobby, + selectionMode = RoomSelectionMode.Vote, + ) + + override suspend fun createRoom(request: CreateRoomRequest): ApiResult { + createRequests += request + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } + + override suspend fun joinRoom(request: JoinRoomRequest): ApiResult { + joinRequests += request + joinResult?.let { return it } + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } + + override suspend fun setSelection( + request: SetSelectionRequest, + ): ApiResult { + selectionRequests += request + nextRoom = nextRoom.copy( + selectedContentId = request.contentId, + selectedFileId = request.fileId, + ) + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } + } +} +``` + +- [ ] **Step 2: Run the phone ViewModel test and verify RED** + +Run: + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherEntryViewModelTest' +``` + +Expected: FAIL because the constructor still takes `WatchTogetherRepository`, +and `hostEmptyVoteRoom`, `resumeCurrentRoom`, and `currentRoom` do not exist. + +- [ ] **Step 3: Implement the phone controller additions** + +Change the constructor to `WatchTogetherEntryGateway`, map current-room state +through the shared policy, and add explicit menu actions: + +```kotlin +class WatchTogetherEntryViewModel( + private val gateway: WatchTogetherEntryGateway, +) : ViewModel() { + val currentRoom: StateFlow = gateway.roomSnapshot + .map(::resumableWatchTogetherRoom) + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + resumableWatchTogetherRoom(gateway.roomSnapshot.value), + ) + + fun hostEmptyVoteRoom() { + if (_uiState.value.busy) return + _uiState.update { it.copy(busy = true, error = null) } + viewModelScope.launch { + when ( + val created = gateway.createRoom( + CreateRoomRequest(selectionMode = RoomSelectionMode.Vote.wire), + ) + ) { + is ApiResult.Success -> finish(created.data.room) + is ApiResult.Error, is ApiResult.NetworkError -> + fail(created.errorMessage("Failed to create room")) + } + } + } + + fun resumeCurrentRoom() { + if (_uiState.value.busy) return + val room = resumableWatchTogetherRoom(gateway.roomSnapshot.value) + if (room == null) { + fail("Current room is no longer available") + } else { + finish(room) + } + } +} +``` + +Within existing `host`, delegate vote mode before starting HostPick work: + +```kotlin +if (selectionMode == RoomSelectionMode.Vote) { + hostEmptyVoteRoom() + return +} +``` + +Replace repository calls in this ViewModel with `gateway` calls. Keep +`errorMessage`, the busy guard, one-shot destination consumption, and +title-selection ordering unchanged. Change the title-detail sheet's +`onHostVote` path to `viewModel.hostEmptyVoteRoom()` so it uses the explicit +empty-vote action without dummy content. + +- [ ] **Step 4: Map phone routes through the shared target policy** + +Keep the public phone helper name stable: + +```kotlin +fun watchTogetherDestination(room: RoomSnapshot): String = + when (watchTogetherEntryTarget(room)) { + WatchTogetherEntryTarget.Player -> Route.Player( + contentId = requireNotNull(room.selectedContentId), + fileId = room.selectedFileId, + roomId = room.roomId, + ).route + WatchTogetherEntryTarget.Lobby -> + Route.WatchTogetherLobby(roomId = room.roomId).route + } +``` + +- [ ] **Step 5: Run the focused phone entry tests** + +Run: + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherEntryViewModelTest' \ + --tests 'org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherEntryDestinationTest' +``` + +Expected: PASS. + +- [ ] **Step 6: Commit the phone controller** + +```bash +git add \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModel.kt \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntrySheet.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModelTest.kt +git commit -m "feat: add phone Watch Together menu actions" +``` + +## Task 3: Phone transient sheet and all profile-menu rows + +**Files:** +- Create: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySheet.kt` +- Create: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySourceTest.kt` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MainAppTopBar.kt:52-181` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt:84-100,291-305,444-525` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt:617-630,1168-1183,1328-1408` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt:120-451` + +**Interfaces:** +- Consumes: `WatchTogetherEntryViewModel.currentRoom`, `hostEmptyVoteRoom`, `resumeCurrentRoom`, and `joinByCode` +- Produces: `@Composable fun WatchTogetherMenuEntrySheet(onNavigate: (String) -> Unit, onDismiss: () -> Unit, viewModel: WatchTogetherEntryViewModel = koinViewModel())` +- Produces: `onWatchTogetherClick: (() -> Unit)?` through each phone chrome/profile-menu signature, supplied only when `CLIENT_WATCH_TOGETHER_SURFACE_ENABLED` is true. + +- [ ] **Step 1: Write the phone source-level RED tests** + +```kotlin +package org.prairieserver.prairie.android.ui.screens.watchtogether + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WatchTogetherMenuEntrySourceTest { + private fun source(path: String) = File("src/androidMain/kotlin/$path").readText() + + private val topBar = source("org/prairieserver/prairie/android/ui/components/MainAppTopBar.kt") + private val home = source("org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt") + private val libraries = source("org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt") + private val main = source("org/prairieserver/prairie/android/ui/screens/MainScreen.kt") + private val menuSheet = source( + "org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySheet.kt", + ) + + @Test + fun everyPhoneProfileMenuPlacesWatchTogetherAfterRequestsAndBeforeSettings() { + listOf(topBar, home, libraries).forEach { text -> + val watch = text.indexOf("Text(\"Watch Together\")") + val requests = text.indexOf("Text(\"Requests\")") + val settings = text.indexOf("Text(\"Settings\")") + assertTrue(watch >= 0) + assertTrue(requests < 0 || requests < watch) + assertTrue(watch < settings) + if (requests >= 0) { + assertFalse( + text.substring( + startIndex = requests + "Text(\"Requests\")".length, + endIndex = watch, + ).contains("DropdownMenuItem("), + ) + } + } + } + + @Test + fun mainShellOwnsOneTransientEntrySheet() { + assertTrue(main.contains("var showWatchTogetherEntry by rememberSaveable")) + assertTrue(main.contains("WatchTogetherMenuEntrySheet(")) + assertTrue(main.contains("CLIENT_WATCH_TOGETHER_SURFACE_ENABLED")) + assertTrue(main.contains("onWatchTogetherClick = watchTogetherMenuAction")) + } + + @Test + fun sheetUsesOnlyTheExistingControllerAndNeverHandlesCredentials() { + assertTrue(menuSheet.contains("viewModel.hostEmptyVoteRoom()")) + assertTrue(menuSheet.contains("viewModel.resumeCurrentRoom()")) + assertTrue(menuSheet.contains("viewModel.joinByCode(code)")) + listOf("Resume current room", "Host a room", "Join by code").forEach { label -> + assertTrue(menuSheet.contains(label)) + } + assertFalse(menuSheet.contains("WatchTogetherApi")) + assertFalse(menuSheet.contains("roomAccessToken")) + assertFalse(menuSheet.contains("HttpClient")) + } +} +``` + +- [ ] **Step 2: Run the phone source test and verify RED** + +Run: + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherMenuEntrySourceTest' +``` + +Expected: FAIL because the menu sheet/file, callbacks, and menu rows do not +exist. + +- [ ] **Step 3: Implement the transient phone sheet** + +The new sheet must collect `uiState` and `currentRoom`, show Resume only when +non-null, and retain the existing join-code rules: + +```kotlin +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WatchTogetherMenuEntrySheet( + onNavigate: (String) -> Unit, + onDismiss: () -> Unit, + viewModel: WatchTogetherEntryViewModel = koinViewModel(), +) { + val state by viewModel.uiState.collectAsState() + val currentRoom by viewModel.currentRoom.collectAsState() + var code by rememberSaveable { mutableStateOf("") } + var showJoin by rememberSaveable { mutableStateOf(false) } + val latestBusy by rememberUpdatedState(state.busy) + + LaunchedEffect(state.destination) { + val destination = state.destination ?: return@LaunchedEffect + viewModel.consumeDestination() + onDismiss() + onNavigate(destination) + } + + ModalBottomSheet( + onDismissRequest = { + if (canDismissRoomEntry(state.busy)) { + viewModel.clearError() + onDismiss() + } + }, + sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { target -> + target != SheetValue.Hidden || canDismissRoomEntry(latestBusy) + }, + ), + ) { + if (!showJoin) { + if (currentRoom != null) { + Button( + onClick = viewModel::resumeCurrentRoom, + enabled = !state.busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Resume current room") + } + } + Button( + onClick = viewModel::hostEmptyVoteRoom, + enabled = !state.busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (state.busy) "Creating…" else "Host a room") + } + OutlinedButton( + onClick = { + viewModel.clearError() + showJoin = true + }, + enabled = !state.busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Join by code") + } + } else { + OutlinedTextField( + value = code, + onValueChange = { code = it.uppercase().filter(Char::isLetterOrDigit).take(8) }, + label = { Text("Invite code") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { viewModel.joinByCode(code) }, + enabled = !state.busy && code.length >= 4, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (state.busy) "Joining…" else "Join") + } + OutlinedButton( + onClick = { + viewModel.clearError() + showJoin = false + }, + enabled = !state.busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Back") + } + } + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } +} +``` + +Render `Text("Watch Together")` with `titleMedium`, `FontWeight.Bold`, and +`Modifier.padding(horizontal = 20.dp, vertical = 12.dp)`, followed by an +`HorizontalDivider`. Wrap the action branch in a full-width `Column` with +`Modifier.padding(20.dp)` and `Arrangement.spacedBy(12.dp)`, and end the sheet +with `Spacer(Modifier.height(24.dp))`. Use an 18.dp +`CircularProgressIndicator` for the busy Join button. Do not import +network/API/token types. + +- [ ] **Step 4: Thread and render the phone menu action** + +Add this exact parameter to `MainAppTopBar`, `HomeScreen`, +`HomeFloatingChrome`, `HomeProfileMenu`, `LibrariesScreen`, +`LibrariesFloatingChrome`, and `ChromeProfileMenu`: + +```kotlin +onWatchTogetherClick: (() -> Unit)?, +``` + +In all three menu implementations, keep the conditional Requests item first, +insert Watch Together immediately after it, and keep one divider after the +content/action group. When Requests is absent, Watch Together is therefore the +last content action before the divider: + +```kotlin +if (onRequestsClick != null) { + DropdownMenuItem( + text = { Text("Requests") }, + onClick = { + menuExpanded = false + onRequestsClick() + }, + ) +} +if (onWatchTogetherClick != null) { + DropdownMenuItem( + text = { Text("Watch Together") }, + onClick = { + menuExpanded = false + onWatchTogetherClick() + }, + ) +} +HorizontalDivider() +``` + +In `MainScreen`, add one saved surface flag and use one callback at all phone +menu call sites: + +```kotlin +import org.prairieserver.prairie.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED + +var showWatchTogetherEntry by rememberSaveable { mutableStateOf(false) } +val watchTogetherMenuAction: (() -> Unit)? = + if (CLIENT_WATCH_TOGETHER_SURFACE_ENABLED) { + { showWatchTogetherEntry = true } + } else { + null + } +``` + +Pass: + +```kotlin +onWatchTogetherClick = watchTogetherMenuAction, +``` + +Render beside the existing library and SiloCast sheets: + +```kotlin +if (showWatchTogetherEntry) { + WatchTogetherMenuEntrySheet( + onNavigate = { route -> + navController.navigate(route) { + launchSingleTop = true + } + }, + onDismiss = { showWatchTogetherEntry = false }, + ) +} +``` + +- [ ] **Step 5: Run phone menu tests and compile** + +Run: + +```bash +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidApp:assembleDebug \ + --tests 'org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherMenuEntrySourceTest' \ + --max-workers=2 +``` + +Expected: PASS. + +- [ ] **Step 6: Commit the phone surface** + +```bash +git add \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySheet.kt \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MainAppTopBar.kt \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySourceTest.kt +git commit -m "feat: add Watch Together to phone profile menus" +``` + +## Task 4: Phone browse-versus-leave continuity and owner authority + +**Files:** +- Create: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyContinuitySourceTest.kt` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt:75-167` + +**Interfaces:** +- Consumes: existing `WatchTogetherLobbyViewModel.leave`, `vote`, `unvote`, `promote`, and `closeRoom` +- Produces: ordinary Back/browse invokes `onBack()` without `leave()` +- Produces: an explicit **Leave room** action invokes `viewModel.leave()` then `onBack()` +- Preserves: host Close, suggestion vote/promote, and title-detail **Suggest to Watch Together**. + +- [ ] **Step 1: Write the phone lobby continuity RED test** + +```kotlin +package org.prairieserver.prairie.android.ui.screens.watchtogether + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WatchTogetherLobbyContinuitySourceTest { + private val lobby = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt", + ).readText() + private val detail = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt", + ).readText() + + @Test + fun ordinaryBackBrowsesWithoutLeavingAndLeaveIsExplicit() { + val navigationIcon = lobby.substringAfter("navigationIcon = {").substringBefore("actions = {") + assertTrue(navigationIcon.contains("onClick = onBack")) + assertFalse(navigationIcon.contains("viewModel.leave()")) + assertTrue(lobby.contains("Text(\"Leave room\")")) + assertTrue(lobby.contains("viewModel.leave()")) + } + + @Test + fun ownerControlsAndTitleSuggestionRemainReachable() { + assertTrue(lobby.contains("viewModel.vote(s.id)")) + assertTrue(lobby.contains("viewModel.promote(s.id)")) + assertTrue(lobby.contains("viewModel.closeRoom()")) + assertTrue(detail.contains("Suggest to Watch Together")) + assertTrue(detail.contains("suggestViewModel.suggest(")) + } +} +``` + +- [ ] **Step 2: Run the continuity test and verify RED** + +Run: + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherLobbyContinuitySourceTest' +``` + +Expected: FAIL because the visible navigation icon currently calls `leave()` +and no explicit Leave row exists. + +- [ ] **Step 3: Separate browse/back from explicit Leave** + +Replace the top app bar navigation behavior with: + +```kotlin +navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back to browse", + ) + } +}, +actions = { + TextButton( + onClick = { + viewModel.leave() + onBack() + }, + ) { + Text("Leave room") + } + if (canManage) { + TextButton(onClick = { viewModel.closeRoom() }) { + Text("Close") + } + } +}, +``` + +Do not add any reset to `onCleared` or ordinary `onBack`; `RoomSession` remains +the process owner while the user browses to a detail screen and submits the +existing suggestion action. + +- [ ] **Step 4: Run phone Watch Together regression tests** + +Run: + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.android.ui.screens.watchtogether.*' +``` + +Expected: PASS. + +- [ ] **Step 5: Commit phone continuity** + +```bash +git add \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyContinuitySourceTest.kt +git commit -m "fix: preserve phone room while browsing" +``` + +## Task 5: TV entry controller and shared destination routing + +**Files:** +- Create: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestination.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModelTest.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestinationTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModel.kt:18-113` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt:1048-1104` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt:630-712` +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherSurfaceSourceTest.kt:31-43` + +**Interfaces:** +- Consumes: `WatchTogetherEntryGateway`, `watchTogetherEntryTarget`, and `resumableWatchTogetherRoom` +- Produces: `val currentRoom: StateFlow` +- Produces: `fun createEmptyVoteRoom()` +- Produces: `fun resumeCurrentRoom()` +- Produces: `fun tvWatchTogetherDestination(room: RoomSnapshot): String` +- Preserves: title-bound `createRoom(contentId, fileId, selectionMode)` and `joinRoom(code)`. + +- [ ] **Step 1: Write the TV RED tests** + +Create `TvWatchTogetherViewModelTest` with an +`UnconfinedTestDispatcher`, `Dispatchers.setMain`/`resetMain`, these tests, and +the complete local fake below: + +```kotlin +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest +import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomResponse +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class TvWatchTogetherViewModelTest { + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + +@Test +fun emptyHostCreatesVoteRoomWithoutSelection() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = TvWatchTogetherViewModel(gateway) + + viewModel.createEmptyVoteRoom() + + assertEquals(listOf(CreateRoomRequest(RoomSelectionMode.Vote.wire)), gateway.createRequests) + assertEquals(emptyList(), gateway.selectionRequests) + assertEquals("room-1", viewModel.uiState.value.result?.roomId) +} + +@Test +fun resumeUsesCurrentRoomWithoutNetworkCalls() = runTest(dispatcher) { + val room = RoomSnapshot(roomId = "room-1", phase = RoomPhase.Lobby) + val gateway = FakeGateway(room) + val viewModel = TvWatchTogetherViewModel(gateway) + + viewModel.resumeCurrentRoom() + + assertEquals(room, viewModel.uiState.value.result) + assertEquals(emptyList(), gateway.createRequests) + assertEquals(emptyList(), gateway.joinRequests) +} + +@Test +fun joinCodeNormalizesAndKeepsExistingErrorCopy() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = TvWatchTogetherViewModel(gateway) + + viewModel.joinRoom(" abcd1234 ") + assertEquals(listOf(JoinRoomRequest(code = "ABCD1234")), gateway.joinRequests) + + viewModel.consumeResult() + gateway.joinResult = ApiResult.NetworkError(IllegalStateException("offline")) + viewModel.joinRoom("efgh5678") + assertEquals("Network error. Check your connection.", viewModel.uiState.value.error) +} + +@Test +fun titleDetailHostStillSetsSelection() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = TvWatchTogetherViewModel(gateway) + + viewModel.createRoom(contentId = "movie-1", fileId = 7) + + assertEquals( + listOf(SetSelectionRequest(contentId = "movie-1", fileId = 7)), + gateway.selectionRequests, + ) +} + +private class FakeGateway( + room: RoomSnapshot? = null, +) : WatchTogetherEntryGateway { + override val roomSnapshot = MutableStateFlow(room) + val createRequests = mutableListOf() + val joinRequests = mutableListOf() + val selectionRequests = mutableListOf() + var joinResult: ApiResult? = null + var nextRoom = RoomSnapshot( + roomId = "room-1", + phase = RoomPhase.Lobby, + selectionMode = RoomSelectionMode.Vote, + ) + + override suspend fun createRoom( + request: CreateRoomRequest, + ): ApiResult { + createRequests += request + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } + + override suspend fun joinRoom( + request: JoinRoomRequest, + ): ApiResult { + joinRequests += request + joinResult?.let { return it } + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } + + override suspend fun setSelection( + request: SetSelectionRequest, + ): ApiResult { + selectionRequests += request + nextRoom = nextRoom.copy( + selectedContentId = request.contentId, + selectedFileId = request.fileId, + ) + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } +} +} +``` + +Add route-policy coverage: + +```kotlin +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.tv.ui.navigation.TvRoute +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvWatchTogetherDestinationTest { + @Test + fun emptyAndSoloHostRoomsUseLobby() { + assertEquals( + TvRoute.WatchTogetherLobby("room-1").route, + tvWatchTogetherDestination(RoomSnapshot(roomId = "room-1")), + ) + assertEquals( + TvRoute.WatchTogetherLobby("room-1").route, + tvWatchTogetherDestination( + RoomSnapshot( + roomId = "room-1", + selectedContentId = "movie-1", + selfRole = MemberRole.Host, + memberCount = 1, + ), + ), + ) + } + + @Test + fun selectedJoinedRoomUsesSyncedPlayer() { + val room = RoomSnapshot( + roomId = "room-1", + selectedContentId = "movie-1", + selectedFileId = 7, + selfRole = MemberRole.Guest, + memberCount = 2, + anchorPositionSeconds = 12.5, + ) + assertEquals( + TvRoute.Player( + contentId = "movie-1", + fileId = 7, + roomId = "room-1", + resumePositionSeconds = 12.5, + ).route, + tvWatchTogetherDestination(room), + ) + } +} +``` + +- [ ] **Step 2: Run TV controller tests and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherViewModelTest' \ + --tests 'org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherDestinationTest' +``` + +Expected: FAIL because the gateway constructor, menu methods, and destination +helper do not exist. + +- [ ] **Step 3: Implement the TV controller additions** + +Mirror the phone controller names through TV's existing naming: + +```kotlin +class TvWatchTogetherViewModel( + private val gateway: WatchTogetherEntryGateway, +) : ViewModel() { + val currentRoom: StateFlow = gateway.roomSnapshot + .map(::resumableWatchTogetherRoom) + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + resumableWatchTogetherRoom(gateway.roomSnapshot.value), + ) + + fun createEmptyVoteRoom() { + if (_uiState.value.isBusy) return + _uiState.update { it.copy(isBusy = true, error = null) } + viewModelScope.launch { + when ( + val created = gateway.createRoom( + CreateRoomRequest(selectionMode = RoomSelectionMode.Vote.wire), + ) + ) { + is ApiResult.Success -> finish(created.data.room) + is ApiResult.Error, is ApiResult.NetworkError -> + fail(created.errorMessage("Failed to create room")) + } + } + } + + fun resumeCurrentRoom() { + if (_uiState.value.isBusy) return + val room = resumableWatchTogetherRoom(gateway.roomSnapshot.value) + if (room == null) { + fail("Current room is no longer available") + } else { + finish(room) + } + } +} +``` + +Delegate `RoomSelectionMode.Vote` in existing `createRoom` to +`createEmptyVoteRoom`, replace repository calls with gateway calls, and keep +the title HostPick sequence unchanged. + +- [ ] **Step 4: Implement one TV destination helper and use it from detail** + +```kotlin +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.tv.ui.navigation.TvRoute +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryTarget +import org.prairieserver.prairie.watchtogether.watchTogetherEntryTarget + +fun tvWatchTogetherDestination(room: RoomSnapshot): String = + when (watchTogetherEntryTarget(room)) { + WatchTogetherEntryTarget.Lobby -> + TvRoute.WatchTogetherLobby(room.roomId).route + WatchTogetherEntryTarget.Player -> + TvRoute.Player( + contentId = requireNotNull(room.selectedContentId), + fileId = room.selectedFileId, + roomId = room.roomId, + resumePositionSeconds = room.anchorPositionSeconds + .takeIf { it.isFinite() && it > 0.0 }, + ).route + } +``` + +Replace the inline target calculation in `TvAppNavigation`'s existing detail +callback with: + +```kotlin +onWatchTogether = { snapshot -> + navController.navigate(tvWatchTogetherDestination(snapshot)) +}, +``` + +Change the title-detail vote callback to `createEmptyVoteRoom()`; keep its +normal Host callback title-bound. + +Update `TvWatchTogetherSurfaceSourceTest.aResolvedRoomReachesTheNavigationCallback` +to assert: + +```kotlin +assertTrue(appNavigation.contains("tvWatchTogetherDestination(snapshot)")) +``` + +- [ ] **Step 5: Run TV controller, route, and existing surface tests** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.watchtogether.*' +``` + +Expected: PASS. + +- [ ] **Step 6: Commit TV controller/routing** + +```bash +git add \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestination.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModel.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModelTest.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestinationTest.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherSurfaceSourceTest.kt +git commit -m "feat: add TV Watch Together menu actions" +``` + +## Task 6: TV profile row, focusable popup, and Back restoration + +**Files:** +- Create: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntryDialog.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntrySourceTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt:166-180,607-610,1270-1313,1478-1563` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt:480-545` +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusStateTest.kt` + +**Interfaces:** +- Produces: `enum class TvWatchTogetherMenuInitialAction { Resume, Host }` +- Produces: `fun tvWatchTogetherMenuInitialAction(canResume: Boolean): TvWatchTogetherMenuInitialAction` +- Produces: `@Composable fun TvWatchTogetherMenuEntryDialog(...)` +- Adds to `TvMainShell`: `onOpenWatchTogether: (RoomSnapshot) -> Unit` +- Consumes: existing `TvJoinCodeDialog`, `TvWatchTogetherViewModel`, `TvProfileDropdown`, and `TvShellFocusState`. + +- [ ] **Step 1: Write TV popup/menu/focus RED tests** + +```kotlin +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvWatchTogetherMenuEntrySourceTest { + private val shell = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt", + ).readText() + private val dialog = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntryDialog.kt", + ).readText() + + @Test + fun profileRowIsImmediatelyAfterRequestsAndBeforeSettings() { + val profile = shell.substringAfter("private fun TvProfileDropdown(") + val watch = profile.indexOf("label = \"Watch Together\"") + val requests = profile.indexOf("label = \"Requests\"") + val settings = profile.indexOf("label = \"Settings\"") + assertTrue(watch >= 0) + assertTrue(requests < watch) + assertTrue(watch < settings) + assertFalse( + profile.substring( + startIndex = requests + "label = \"Requests\"".length, + endIndex = watch, + ).contains("ProfileDropdownRow("), + ) + assertTrue(shell.contains("CLIENT_WATCH_TOGETHER_SURFACE_ENABLED")) + } + + @Test + fun popupOwnsFocusAndBackRestoresProfileFocus() { + assertTrue(dialog.contains("PopupProperties(")) + assertTrue(dialog.contains("focusable = true")) + assertTrue(dialog.contains("rememberTvDialogInitialFocus(initialFocus)")) + assertTrue(shell.contains("focusState.closeProfileMenuForContent()")) + assertTrue(shell.contains("focusState.dismissProfileMenu()")) + } + + @Test + fun menuSurfaceUsesExistingControllerAndNoCredentials() { + assertTrue(shell.contains("watchTogetherViewModel.createEmptyVoteRoom()")) + assertTrue(shell.contains("watchTogetherViewModel.resumeCurrentRoom()")) + assertTrue(shell.contains("TvJoinCodeDialog(")) + listOf("Resume current room", "Host a room", "Join by code").forEach { label -> + assertTrue(dialog.contains(label)) + } + assertFalse(dialog.contains("WatchTogetherApi")) + assertFalse(dialog.contains("roomAccessToken")) + assertFalse(dialog.contains("HttpClient")) + } + + @Test + fun initialActionPrefersResumeOnlyWhenAvailable() { + assertEquals( + TvWatchTogetherMenuInitialAction.Resume, + tvWatchTogetherMenuInitialAction(canResume = true), + ) + assertEquals( + TvWatchTogetherMenuInitialAction.Host, + tvWatchTogetherMenuInitialAction(canResume = false), + ) + } +} +``` + +Add a focus-state regression to `TvShellFocusStateTest`: + +```kotlin +@Test +fun closingMenuForPopupThenDismissingPopupRefocusesAvatar() { + val state = TvShellFocusState() + state.previewProfileMenu() + state.enterProfileMenu() + val before = state.profileFocusRequest + + state.closeProfileMenuForContent() + assertEquals(before, state.profileFocusRequest) + + state.dismissProfileMenu() + assertEquals(before + 1, state.profileFocusRequest) +} +``` + +- [ ] **Step 2: Run TV popup tests and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherMenuEntrySourceTest' \ + --tests 'org.prairieserver.prairie.tv.ui.shell.TvShellFocusStateTest' +``` + +Expected: FAIL because the dialog, row, callback, and initial-focus policy do +not exist. + +- [ ] **Step 3: Implement the focused TV popup** + +```kotlin +enum class TvWatchTogetherMenuInitialAction { + Resume, + Host, +} + +fun tvWatchTogetherMenuInitialAction( + canResume: Boolean, +): TvWatchTogetherMenuInitialAction = + if (canResume) { + TvWatchTogetherMenuInitialAction.Resume + } else { + TvWatchTogetherMenuInitialAction.Host + } +``` + +The popup uses stable requesters and selects the initial requester from the +pure policy: + +```kotlin +@Composable +fun TvWatchTogetherMenuEntryDialog( + canResume: Boolean, + isBusy: Boolean, + error: String?, + onResume: () -> Unit, + onHost: () -> Unit, + onJoin: () -> Unit, + onDismiss: () -> Unit, +) { + val resumeFocus = remember { FocusRequester() } + val hostFocus = remember { FocusRequester() } + val initialAction = tvWatchTogetherMenuInitialAction(canResume) + val initialFocus = when (initialAction) { + TvWatchTogetherMenuInitialAction.Resume -> resumeFocus + TvWatchTogetherMenuInitialAction.Host -> hostFocus + } + + Popup( + alignment = Alignment.Center, + onDismissRequest = { + if (canDismissRoomEntry(isBusy)) onDismiss() + }, + properties = PopupProperties( + focusable = true, + dismissOnBackPress = canDismissRoomEntry(isBusy), + dismissOnClickOutside = canDismissRoomEntry(isBusy), + clippingEnabled = false, + ), + ) { + Column( + modifier = Modifier + .width(340.dp) + .then(rememberTvDialogInitialFocus(initialFocus)), + ) { + if (canResume) { + TvDialogActionRow( + title = "Resume current room", + enabled = !isBusy, + onClick = onResume, + modifier = Modifier.focusRequester(resumeFocus), + ) + } + TvDialogActionRow( + title = if (isBusy) "Working…" else "Host a room", + enabled = !isBusy, + onClick = onHost, + modifier = Modifier.focusRequester(hostFocus), + ) + TvDialogActionRow( + title = "Join by code", + enabled = !isBusy, + onClick = onJoin, + ) + error?.let { Text(it, color = Color(0xFFEF4444)) } + } + } +} +``` + +Wrap the column in a full-screen centered `Box` padded +`start = 36.dp, top = 50.dp, end = 36.dp, bottom = 42.dp`. Give the 340.dp +column a 14.dp rounded shape, `DarkBackground.copy(alpha = 0.68f)`, a 0.6.dp +`Color.White.copy(alpha = 0.20f)` border, 14.dp horizontal/vertical padding, +and 10.dp item spacing. Render the heading as `"WATCH TOGETHER"` using +`labelMedium`, 16.sp, 1.1.sp letter spacing, bold weight, and white at 0.58 +alpha. + +- [ ] **Step 4: Wire popup state and the profile row into `TvMainShell`** + +Add the callback: + +```kotlin +import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED +import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherMenuEntryDialog +import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherViewModel + +onOpenWatchTogether: (RoomSnapshot) -> Unit, +``` + +Collect the existing controller and own transient flags in the shell: + +```kotlin +val watchTogetherViewModel = koinViewModel() +val watchTogetherState by watchTogetherViewModel.uiState.collectAsState() +val currentWatchTogetherRoom by watchTogetherViewModel.currentRoom.collectAsState() +var watchTogetherEntryOpen by rememberSaveable { mutableStateOf(false) } +var watchTogetherJoinOpen by rememberSaveable { mutableStateOf(false) } +``` + +Consume one-shot results: + +```kotlin +LaunchedEffect(watchTogetherState.result) { + val room = watchTogetherState.result ?: return@LaunchedEffect + watchTogetherViewModel.consumeResult() + watchTogetherEntryOpen = false + watchTogetherJoinOpen = false + onOpenWatchTogether(room) +} +``` + +Add `showWatchTogether: Boolean` and `onWatchTogether: () -> Unit` to +`TvProfileDropdown`; pass +`showWatchTogether = CLIENT_WATCH_TOGETHER_SURFACE_ENABLED`, and invoke +`focusState.closeProfileMenuForContent()` before opening the popup. Insert: + +```kotlin +onWatchTogether = { + focusState.closeProfileMenuForContent() + watchTogetherViewModel.clearError() + watchTogetherEntryOpen = true +}, + +if (showWatchTogether) { + ProfileDropdownRow( + label = "Watch Together", + icon = Icons.Filled.People, + onClick = onWatchTogether, + ) +} +``` + +Place it immediately after the conditional Requests row. When Requests is +hidden, Watch Together remains after History and before the content/settings +divider. + +Render `TvJoinCodeDialog` or `TvWatchTogetherMenuEntryDialog` after the profile +dropdown: + +```kotlin +if (watchTogetherEntryOpen) { + if (watchTogetherJoinOpen) { + TvJoinCodeDialog( + isBusy = watchTogetherState.isBusy, + error = watchTogetherState.error, + onJoin = watchTogetherViewModel::joinRoom, + onDismiss = { + watchTogetherViewModel.clearError() + watchTogetherJoinOpen = false + }, + ) + } else { + TvWatchTogetherMenuEntryDialog( + canResume = currentWatchTogetherRoom != null, + isBusy = watchTogetherState.isBusy, + error = watchTogetherState.error, + onResume = watchTogetherViewModel::resumeCurrentRoom, + onHost = watchTogetherViewModel::createEmptyVoteRoom, + onJoin = { + watchTogetherViewModel.clearError() + watchTogetherJoinOpen = true + }, + onDismiss = { + watchTogetherViewModel.clearError() + watchTogetherEntryOpen = false + watchTogetherJoinOpen = false + focusState.dismissProfileMenu() + }, + ) + } +} +``` + +Switching from entry to join does not restore avatar focus; only dismissal of +the outer entry popup does. + +- [ ] **Step 5: Route shell results through the existing root destinations** + +At the `TvMainShell` call in `TvAppNavigation`, pass: + +```kotlin +onOpenWatchTogether = { room -> + navController.navigate(tvWatchTogetherDestination(room)) { + launchSingleTop = true + } +}, +``` + +Do not add a new `TvRoute`. + +- [ ] **Step 6: Run TV popup, shell focus, and compile gates** + +Run: + +```bash +./gradlew \ + :androidTvApp:testDebugUnitTest \ + :androidTvApp:assembleDebug \ + --tests 'org.prairieserver.prairie.tv.ui.screens.watchtogether.*' \ + --tests 'org.prairieserver.prairie.tv.ui.shell.TvShellFocusStateTest' \ + --max-workers=2 +``` + +Expected: PASS. + +- [ ] **Step 7: Commit the TV surface** + +```bash +git add \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntryDialog.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntrySourceTest.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusStateTest.kt +git commit -m "feat: add Watch Together to TV profile menu" +``` + +## Task 7: TV browse-versus-leave continuity and owner authority + +**Files:** +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyContinuitySourceTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt:120-175,250-330` + +**Interfaces:** +- Consumes: existing `onBack`, `TvWatchTogetherLobbyViewModel.leave`, `vote`, `unvote`, `promote`, and `closeRoom` +- Produces: D-pad Back and **Browse titles** return without leaving. +- Produces: explicit **Leave room** clears through `RoomSession.depart`. +- Preserves: host Close, host override, suggestion voting, and title-detail suggestion entry. + +- [ ] **Step 1: Write the TV lobby continuity RED test** + +```kotlin +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvWatchTogetherLobbyContinuitySourceTest { + private val lobby = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt", + ).readText() + private val detail = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt", + ).readText() + + @Test + fun backAndBrowsePreserveRoomWhileLeaveIsExplicit() { + val backHandler = lobby.substringAfter("BackHandler(enabled = true)") + .substringBefore("Box(") + assertTrue(backHandler.contains("onBack()")) + assertFalse(backHandler.contains("viewModel.leave()")) + assertTrue(lobby.contains("title = \"Browse titles\"")) + assertTrue(lobby.contains("title = \"Leave room\"")) + assertTrue(lobby.contains("viewModel.leave()")) + } + + @Test + fun existingOwnerAuthorityAndSuggestionPathRemain() { + assertTrue(lobby.contains("CloseRoomButton(onClick = viewModel::closeRoom)")) + assertTrue(lobby.contains("viewModel.vote(s.id)")) + assertTrue(lobby.contains("viewModel.promote(s.id)")) + assertTrue(detail.contains("Suggest to Watch Together")) + assertTrue(detail.contains("suggestViewModel.suggest(")) + } +} +``` + +- [ ] **Step 2: Run the TV continuity test and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherLobbyContinuitySourceTest' +``` + +Expected: FAIL because TV Back currently calls `leave()` and the two explicit +rows do not exist. + +- [ ] **Step 3: Make TV Back browse and add explicit Browse/Leave rows** + +Change ordinary Back: + +```kotlin +BackHandler(enabled = true) { + onBack() +} +``` + +Before host-only policy/close controls, add: + +```kotlin +TvDialogActionRow( + title = "Browse titles", + onClick = onBack, +) +TvDialogActionRow( + title = "Leave room", + onClick = { + viewModel.leave() + onBack() + }, +) +``` + +Keep the terminal `closedReason` effect calling `leave()` and keep the explicit +host `CloseRoomButton`; those are teardown actions, unlike ordinary browse. + +- [ ] **Step 4: Run all TV Watch Together and focus tests** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.watchtogether.*' \ + --tests 'org.prairieserver.prairie.tv.ui.shell.TvShellFocusStateTest' +``` + +Expected: PASS. + +- [ ] **Step 5: Commit TV continuity** + +```bash +git add \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyContinuitySourceTest.kt +git commit -m "fix: preserve TV room while browsing" +``` + +## Task 8: Preserve owner operations, identity teardown, auth, cleartext, and error boundaries + +**Files:** +- Modify: `shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepositoryTest.kt:40-143,776-866` +- Modify: `shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomSessionTest.kt:122-184` +- Modify: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySourceTest.kt` +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntrySourceTest.kt` + +**Interfaces:** +- Consumes only existing repository methods: `addSuggestion`, `vote`, `promoteSuggestion`, `closeRoom`, and identity-transition reset. +- Produces test evidence that the top-level entry changes presentation only. +- Must not modify `WatchTogetherApi.kt`, `WatchTogetherModels.kt`, `WatchTogetherRealtimeClient.kt`, `CleartextOriginConsent.kt`, or server/proxy files. + +- [ ] **Step 1: Add a repository authority characterization** + +Extend the existing `FakeApi` with counters for create, suggestion, vote, +promote, and close, then add: + +```kotlin +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode + +var createCalls = 0 +var addSuggestionCalls = 0 +var voteCalls = 0 +var promoteCalls = 0 +var closeCalls = 0 + +override suspend fun createRoom( + request: CreateRoomRequest, + scope: AuthScopeSnapshot, +): ApiResult { + createCalls++ + lastAuthScope = scope + return createResult?.await() ?: createResponse +} + +override suspend fun addSuggestion( + roomId: String, + roomToken: String, + request: AddSuggestionRequest, + scope: AuthScopeSnapshot, +): ApiResult { + addSuggestionCalls++ + lastRoomToken = roomToken + lastAuthScope = scope + return ApiResult.Success(SuggestionsResponse()) +} + +override suspend fun vote( + roomId: String, + roomToken: String, + suggestionId: String, + scope: AuthScopeSnapshot, +): ApiResult { + voteCalls++ + lastRoomToken = roomToken + lastAuthScope = scope + return ApiResult.Success(SuggestionsResponse()) +} + +override suspend fun promoteSuggestion( + roomId: String, + roomToken: String, + request: PromoteSuggestionRequest, + scope: AuthScopeSnapshot, +): ApiResult { + promoteCalls++ + lastRoomToken = roomToken + lastAuthScope = scope + return createResponse +} + +override suspend fun closeRoom( + roomId: String, + roomToken: String, + scope: AuthScopeSnapshot, +): ApiResult { + closeCalls++ + lastRoomToken = roomToken + lastAuthScope = scope + return ApiResult.Success(Unit) +} + +@Test +fun `empty vote room owner keeps existing suggestion vote override and close authority`() = runTest { + val api = FakeApi( + createResponse = ApiResult.Success( + RoomResponse( + room = RoomSnapshot( + roomId = "room-1", + selectionMode = RoomSelectionMode.Vote, + selfRole = MemberRole.Host, + selfCanManageRoom = true, + ), + roomAccessToken = "room-token", + ), + ), + ) + val repository = WatchTogetherRepository( + api = api, + authScopeProvider = { scopeA }, + ) + + repository.createRoom(CreateRoomRequest(selectionMode = RoomSelectionMode.Vote.wire)) + repository.addSuggestion( + AddSuggestionRequest( + contentId = "movie-1", + contentType = "movie", + title = "Movie One", + ), + ) + repository.vote("suggestion-1") + repository.promoteSuggestion(PromoteSuggestionRequest("suggestion-1")) + repository.closeRoom() + + assertEquals(1, api.createCalls) + assertEquals(1, api.addSuggestionCalls) + assertEquals(1, api.voteCalls) + assertEquals(1, api.promoteCalls) + assertEquals(1, api.closeCalls) + assertEquals("room-token", api.lastRoomToken) +} +``` + +This is characterization of current authority; it should pass without +production changes. + +- [ ] **Step 2: Add an explicit same-profile/session lifecycle assertion** + +Add to `RoomSessionTest`: + +```kotlin +@Test +fun `room remains adopted until explicit leave or identity transition`() = runTest { + val repository = FakeRoomSessionRepository() + val barrier = DefaultIdentityTransitionBarrier() + val session = RoomSession(repository, backgroundScope, barrier) + + session.adopt("room-a").join() + runCurrent() + assertTrue(session.isActive()) + assertEquals(0, repository.resetCount) + + barrier.changing(IdentityTransitionKind.PROFILE_SWITCH) { + assertTrue(!session.isActive()) + assertEquals(1, repository.resetCount) + } +} +``` + +Add a dedicated logout assertion so the profile-switch example is not treated +as sufficient coverage for sign-out: + +```kotlin +@Test +fun `sign out clears the adopted room before identity mutation`() = runTest { + val repository = FakeRoomSessionRepository() + val barrier = DefaultIdentityTransitionBarrier() + val session = RoomSession(repository, backgroundScope, barrier) + session.adopt("room-a").join() + runCurrent() + + barrier.changing(IdentityTransitionKind.SIGN_OUT) { + assertTrue(!session.isActive()) + assertEquals(1, repository.resetCount) + } +} +``` + +Retain the existing all-transition-kinds regression so `SERVER_SWITCH`, +`PROFILE_SWITCH`, and future identity transition kinds remain covered as well. + +- [ ] **Step 3: Strengthen source boundary assertions** + +In both menu source tests, assert that the new UI files do not contain: + +```kotlin +assertFalse(source.contains("room_token")) +assertFalse(source.contains("roomAccessToken")) +assertFalse(source.contains("Authorization")) +assertFalse(source.contains("CleartextOriginConsent")) +``` + +Also assert error presentation still comes from each existing ViewModel state: + +```kotlin +assertTrue(source.contains("state.error")) +``` + +- [ ] **Step 4: Run authority, lifecycle, cleartext, and entry error suites** + +Run: + +```bash +./gradlew :shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.repository.WatchTogetherRepositoryTest' \ + --tests 'org.prairieserver.prairie.watchtogether.RoomSessionTest' +./gradlew :androidApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.android.ui.screens.auth.ServerSetupCleartextWarningTest' \ + --tests 'org.prairieserver.prairie.android.ui.screens.watchtogether.*' +./gradlew :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.auth.TvServerSetupCleartextWarningTest' \ + --tests 'org.prairieserver.prairie.tv.ui.screens.watchtogether.*' \ + --max-workers=2 +``` + +Expected: PASS. + +- [ ] **Step 5: Verify no protocol, cleartext-policy, or server files changed** + +Run: + +```bash +test -z "$(git diff --name-only origin/main...HEAD -- \ + shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/WatchTogetherApi.kt \ + shared/src/commonMain/kotlin/org/prairieserver/prairie/model/watchtogether/WatchTogetherModels.kt \ + shared/src/commonMain/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeClient.kt \ + shared/src/commonMain/kotlin/org/prairieserver/prairie/network/CleartextOriginConsent.kt)" +``` + +Expected: exit 0 and no output. + +- [ ] **Step 6: Commit the preservation tests** + +```bash +git add \ + shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepositoryTest.kt \ + shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomSessionTest.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySourceTest.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntrySourceTest.kt +git commit -m "test: lock Watch Together menu boundaries" +``` + +## Task 9: Full verification, two-client device smoke, and review gate + +**Files:** +- Verify only; do not create a tracked evidence file containing device IDs, + invite codes, tokens, URLs, or logs. + +**Interfaces:** +- Consumes the complete feature from Tasks 1-8. +- Produces fresh build/test/device evidence for review. + +- [ ] **Step 1: Run formatting/diff and supply-chain policy checks** + +Run: + +```bash +git diff --check origin/main...HEAD +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +``` + +Expected: all commands exit 0. + +- [ ] **Step 2: Run focused tests once more** + +Run: + +```bash +./gradlew :shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.watchtogether.WatchTogetherEntryPolicyTest' \ + --tests 'org.prairieserver.prairie.repository.WatchTogetherRepositoryTest' \ + --tests 'org.prairieserver.prairie.watchtogether.RoomSessionTest' +./gradlew :androidApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.android.ui.screens.watchtogether.*' \ + --max-workers=2 +./gradlew :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.watchtogether.*' \ + --tests 'org.prairieserver.prairie.tv.ui.shell.TvShellFocusStateTest' \ + --max-workers=2 +``` + +Expected: PASS with zero failed tests. + +- [ ] **Step 3: Run the full unit and debug build gate** + +Run: + +```bash +./gradlew \ + test \ + :androidApp:assembleDebug \ + :androidTvApp:assembleDebug \ + --max-workers=2 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Run minified release assembly for both clients** + +Use the repository-supported local signing path only: + +```bash +./gradlew \ + :androidApp:assembleRelease \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --max-workers=2 +``` + +Expected: `BUILD SUCCESSFUL`. Do not expose keystore paths, passwords, or +certificate material. + +- [ ] **Step 5: Start or select only dedicated test emulators** + +Prefer the existing `Silo_Phone` and `Silo_TV` AVDs and fixed serials: + +```bash +"${ANDROID_HOME}/emulator/emulator" -avd Silo_TV -port 5554 -no-snapshot-save & +"${ANDROID_HOME}/emulator/emulator" -avd Silo_Phone -port 5556 -no-snapshot-save & +adb -s emulator-5554 wait-for-device +adb -s emulator-5556 wait-for-device +adb -s emulator-5554 shell getprop ro.build.characteristics +adb -s emulator-5556 shell getprop ro.build.characteristics +``` + +Expected: `emulator-5554` reports TV characteristics and `emulator-5556` +reports a phone profile. If either serial belongs to another running device, +stop and choose unused even-numbered emulator ports; never issue an unscoped +`adb install`, `adb shell`, clear-data, or uninstall command. + +- [ ] **Step 6: Install debug APKs only on the matching dedicated emulators** + +Run: + +```bash +adb -s emulator-5556 install -r \ + androidApp/build/outputs/apk/debug/androidApp-arm64-v8a-debug.apk +adb -s emulator-5554 install -r \ + androidTvApp/build/outputs/apk/debug/androidTvApp-arm64-v8a-debug.apk +adb -s emulator-5556 shell am start \ + -n org.prairieserver.prairie/.android.MainActivity +adb -s emulator-5554 shell am start \ + -n org.prairieserver.prairie/.tv.MainTvActivity +``` + +If the output APK names differ, resolve only within each module's +`build/outputs/apk/debug/` directory with `find`, verify ABI using `apkanalyzer`, +then repeat the serial-scoped install. + +- [ ] **Step 7: Execute the phone touch smoke matrix** + +On the authenticated phone test profile: + +1. Open profile menus from Home, Libraries, and For You; verify Requests appears + immediately before Watch Together when enabled, and Watch Together remains + immediately before the settings/account divider when Requests is disabled. +2. Open the sheet; with no room verify Host receives the primary position, + Join opens code input, Back returns to entry, and dismissal returns to the + same tab. +3. Host a room; verify the lobby shows vote mode and no selected title. +4. Use Back to browse without leaving, open a movie/episode detail, and verify + **Suggest to Watch Together** is present and can submit a suggestion. +5. Reopen the profile menu; verify **Resume current room** appears and returns + to the same room. +6. Verify the owner can vote, use the existing host override, and close the + room for everyone. +7. Verify explicit **Leave room** removes Resume. +8. Recreate a room, background/foreground the app, and verify Resume remains. +9. Switch profile or log out on the dedicated test account and verify Resume is + absent after returning to an authenticated shell. +10. Recreate a room, run + `adb -s emulator-5556 shell am force-stop org.prairieserver.prairie`, relaunch the + phone activity, and verify Resume is absent while persisted login/profile + data remains intact. +11. Enter an invalid join code and verify the existing inline error appears + without navigation or crash. + +- [ ] **Step 8: Execute the TV D-pad and cross-client smoke matrix** + +On the authenticated TV test profile: + +1. Open the profile dropdown; verify Watch Together follows History and + precedes Requests/Settings. +2. With no room, select the row and verify Host receives initial focus. Press + Back and verify focus returns to the profile avatar without entering content + behind the popup. +3. Host an empty room and join it from the phone by code. +4. Use TV Back or **Browse titles**; verify the room remains active. Open a + title detail and submit the existing **Suggest to Watch Together** action. +5. Reopen the TV profile popup; verify Resume is first and initially focused. +6. Resume the lobby; vote from both participants, verify the TV owner can + override any room-owned suggestion, then close the room and verify both + clients receive closure. +7. Recreate a room, background and foreground TV with Home/Recent, and verify + Resume persists. +8. Use explicit Leave and verify Resume disappears. +9. Verify Join by code handles D-pad entry, Back-to-entry focus, invalid-code + errors, and selected-room routing to the synchronized player. +10. Confirm the title-detail Watch Together action still hosts with that title + preselected rather than creating an empty room. +11. Recreate a room, run + `adb -s emulator-5554 shell am force-stop org.prairieserver.prairie`, relaunch the + TV activity, and verify the process-scoped Resume action is absent. + +Do not alter production proxy settings or server host-timeout configuration. + +- [ ] **Step 9: Capture non-secret crash/ANR evidence** + +Run immediately after the smoke matrix: + +```bash +adb -s emulator-5556 shell pidof org.prairieserver.prairie +adb -s emulator-5554 shell pidof org.prairieserver.prairie +adb -s emulator-5556 logcat -d -t 1500 | + rg -i 'FATAL EXCEPTION|ANR in org\.siloserver\.prairie|am_crash.*org\.siloserver\.prairie' || true +adb -s emulator-5554 logcat -d -t 1500 | + rg -i 'FATAL EXCEPTION|ANR in org\.siloserver\.prairie|am_crash.*org\.siloserver\.prairie' || true +``` + +Expected: both PIDs exist and neither filtered log contains an app crash or +ANR. Do not save raw logcat if it includes URLs, invite codes, or tokens. + +- [ ] **Step 10: Request independent code and security review** + +Ask the reviewer to inspect: + +- shared gateway/policy as a view over the existing singleton, not a second + owner; +- empty vote-room creation for exactly one create and zero `setSelection`; +- identity and explicit-leave teardown; +- phone/TV route parity; +- TV initial focus and Back restoration; +- title-detail preselection regression; +- absence of credentials, direct API calls, server/protocol changes, and + cleartext bypasses. + +Address findings test-first, rerun the smallest affected focused test, then +rerun Steps 1-4. + +- [ ] **Step 11: Verify final branch state** + +Run: + +```bash +git status --short +git log --oneline --decorate origin/main..HEAD +git diff --stat origin/main...HEAD +git diff --check origin/main...HEAD +``` + +Expected: clean worktree, the spec/plan plus small feature commits, and no +uncommitted or generated files. diff --git a/docs/superpowers/plans/2026-07-28-android-final-ledger-fixes.md b/docs/superpowers/plans/2026-07-28-android-final-ledger-fixes.md new file mode 100644 index 000000000..424b87cfa --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-android-final-ledger-fixes.md @@ -0,0 +1,221 @@ +# Android Final Ledger Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix every current defect validated from the independent 2026-07-28 Android end-to-end ledger before publishing release candidates. + +**Architecture:** Watch Together reconciliation remains centralized in its existing repository and lobby surfaces. Audiobook teardown reuses the application-owned playback lifecycle for asynchronous externally-owned session finalization. The three remaining isolated fixes stay at their current subtitle, authentication, and download ownership boundaries. + +**Tech Stack:** Kotlin, coroutines/Flow, Ktor, Jetpack Compose, Media3, Room, WorkManager, Kotlin test/coroutines-test, Gradle. + +## Global Constraints + +- Android phone and TV only; no server/API/schema/protocol changes. +- Preserve all already-reviewed Watch Together generation, lease, reconnect, and leave semantics. +- No `runBlocking` on UI/ViewModel teardown, arbitrary sleeps, timeout widening, or fire-and-forget global scopes. +- Every behavioral fix starts with a deterministic RED regression. +- Physical devices remain excluded. +- Delete only the verified-unused no-op auth stub; do not alter the installed real auth plugin. + +--- + +### Task 1: Reconcile Watch Together Suggestions and Surface Lobby Errors + +**Files:** +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherRepository.kt` +- Modify: phone and TV Watch Together lobby ViewModels/screens as required by their existing error-effect architecture. +- Test: `shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherRepositoryTest.kt` +- Test: phone and TV lobby unit/source tests. + +**Interfaces:** +- Consumes: existing `Opened`, REST `listSuggestions`, repository `errors`, lobby message/snackbar effects. +- Produces: reconnect hydration, authoritative `votedIds`, and visible transient lobby failures on both clients. + +- [ ] **Step 1: Write reconnect hydration RED** + +Start a socket, publish suggestions, terminate transport, mutate the fake REST +suggestion list, connect the successor socket, emit `Opened`, and assert REST +is called again and the missed mutation is published. Verify a protocol +`Closed` remains terminal and does not refresh/reconnect. + +- [ ] **Step 2: Write authoritative unvote RED** + +Apply a REST list with `votedByMe=true`, then refresh with the same suggestion +set to false. Assert the repository's local vote set removes that ID while +preserving votes still reported true. + +- [ ] **Step 3: Implement repository reconciliation** + +On each healthy `Opened`, refresh suggestions through the existing REST API +without creating a second repository/socket architecture. Replace the +authoritative vote set from REST responses rather than only OR-ing true IDs; +retain optimistic mutation behavior between authoritative refreshes. + +- [ ] **Step 4: Write lobby error RED** + +Make vote/promote/suggest fail in each lobby ViewModel and assert one visible +message effect. Also assert repository transient socket errors are visible +while the lobby is active. + +- [ ] **Step 5: Implement phone/TV error parity** + +Collect the existing repository error flow in the existing lobby lifecycle, +and translate rejected operations through the same one-shot UI message path. +Do not add replay that can show stale errors after navigation. + +- [ ] **Step 6: Run focused GREEN and commit** + +Run repository and both lobby test suites, compile phone/TV, then commit: + +```bash +git commit -m "fix(watch-together): reconcile rooms and surface lobby errors" +``` + +### Task 2: Make Audiobook Teardown Non-Blocking + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycle.kt` +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AudiobookPlayerViewModel.kt` +- Modify: phone and TV DI factories for the shared audiobook ViewModel. +- Test: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleTest.kt` +- Test: a source-contract test for audiobook teardown. + +**Interfaces:** +- Consumes: the lifecycle's existing application scope, `NonCancellable + Dispatchers.IO`, and `PlaybackSessionManager.reportProgress/stopSession`. +- Produces: `reportAndStopExternalSessionAsync(sessionId, positionSeconds, isPaused)` for an externally-owned audiobook session. + +- [ ] **Step 1: Write asynchronous finalization RED** + +Use a fake manager whose progress call suspends. Invoke the new external +finalizer, assert the caller returns before the fake is released, then assert +the exact session/position/pause report precedes stop. + +- [ ] **Step 2: Write audiobook source-contract RED** + +Assert `AudiobookPlayerViewModel.onCleared` contains no `runBlocking` and +submits a synchronously captured active session/position/pause snapshot to the +external finalizer. + +- [ ] **Step 3: Implement the smallest lifecycle reuse** + +Add a coalesced application-scope async operation for the explicit external +session ID. Do not adopt the audiobook session into the video lifecycle and do +not derive the target from lifecycle-owned state. Inject the lifecycle through +both clients' existing factories. + +- [ ] **Step 4: Run focused GREEN and commit** + +Run lifecycle/audiobook tests and phone/TV compilation, then commit: + +```bash +git commit -m "fix(android): finalize audiobook sessions asynchronously" +``` + +### Task 3: Correct TV Top Subtitle Title-Safe Compensation + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt` +- Test: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt` + +**Interfaces:** +- Consumes: subtitle vertical preset and `titleSafeFraction`. +- Produces: unchanged Top base padding; compensated Bottom/LowerThird padding. + +- [ ] **Step 1: Write RED** + +With `titleSafeFraction = 0.05f`, assert Top remains its base `0.74f` padding +while Bottom and LowerThird retain their existing compensation. + +- [ ] **Step 2: Implement GREEN** + +Skip bottom-padding subtraction only for the Top preset; preserve every other +preset and the outer title-safe surface inset. + +- [ ] **Step 3: Verify and commit** + +Run the full subtitle appearance class and compile both clients: + +```bash +git commit -m "fix(tv): avoid double-shifting top subtitles" +``` + +### Task 4: Remove the Unused No-Op Auth Plugin + +**Files:** +- Delete: `shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthInterceptor.kt` + +**Interfaces:** +- Consumes: repository-wide proof that only `PrairieAuthPlugin` is installed. +- Produces: no public no-op authentication symbol that can be installed accidentally. + +- [ ] **Step 1: Prove non-use** + +Search production/tests/build publication metadata for `SiloAuth`; require the +declaration to be the only match and the real `PrairieAuthPlugin` installation to +remain in `SiloHttpClientImpl`. + +- [ ] **Step 2: Delete and compile** + +Delete only the stub, compile shared/phone/TV, and commit: + +```bash +git commit -m "chore(shared): remove unused no-op auth plugin" +``` + +### Task 5: Clear Permanent Download Failure Progress + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorker.kt` +- Test: the existing DownloadWorker/sidecar status test suite. + +**Interfaces:** +- Consumes: existing live-record and sidecar status update helpers. +- Produces: nullable “preserve” byte arguments, with explicit zero clearing both stored representations on permanent failure. + +- [ ] **Step 1: Write RED** + +Seed a download and sidecar with nonzero bytes, invoke the permanent-failure +transition, and assert `Failed`, `bytesSent == 0`, and `fileSize == 0` in both. + +- [ ] **Step 2: Implement GREEN** + +Distinguish omitted/preserve values from explicit zero using nullable +arguments or an equally explicit update type. Preserve existing callers that +intend to retain progress. + +- [ ] **Step 3: Verify and commit** + +Run the focused worker/sidecar tests and compile phone/TV: + +```bash +git commit -m "fix(android): clear progress on permanent download failure" +``` + +### Task 6: Final Integrated Release Qualification + +**Files:** +- Verify only: `origin/main...HEAD`. + +**Interfaces:** +- Consumes: Tasks 1–5 with independent approval. +- Produces: clean reviewed branch, green supply-chain/unit/release gates, final universal APKs, and updated PR #126. + +- [ ] **Step 1: Independently review every task and fix every finding** + +- [ ] **Step 2: Run supply-chain and exact combined phone/TV unit/release gate** + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + :androidApp:assembleRelease \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +- [ ] **Step 3: Require a final whole-branch clean review** + +- [ ] **Step 4: Verify and copy final universal APKs, then push/update PR #126 without merging** diff --git a/docs/superpowers/plans/2026-07-28-android-phone-library-chrome-inset.md b/docs/superpowers/plans/2026-07-28-android-phone-library-chrome-inset.md new file mode 100644 index 000000000..cfbac1e91 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-android-phone-library-chrome-inset.md @@ -0,0 +1,350 @@ +# Android Phone Library Chrome Inset Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep Android phone library content below the fixed library selector and subtab chrome, matching the iOS `safeAreaInset` behavior. + +**Architecture:** Preserve the full-screen backdrop in the existing root `Box`, but replace the content-first/floating-chrome overlay with a foreground `Column`: measured chrome first, then a clipped `Box` with `weight(1f)` for every library state and subtab. Remove the old per-tab status-bar/chrome runways so system insets are consumed once by the shared chrome. + +**Tech Stack:** Kotlin, Jetpack Compose, Material 3, Kotlin/JVM unit tests, Gradle. + +## Global Constraints + +- Android phone only; do not alter Android TV, server APIs, Apple code, or standalone Browse / Collections routes. +- Keep the full-screen Recommended hero backdrop; only interactive/editorial scroll content is confined below the chrome. +- Preserve library/profile menu actions, tab state, scroll state, filters, pagination, grids, hero selection, and bottom-chrome padding. +- The shared library chrome consumes status-bar/display-cutout top inset exactly once. +- Do not target physical devices; emulator validation may use only the dedicated phone emulator when available. + +--- + +### Task 1: Reserve a measured viewport below the shared library chrome + +**Files:** +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt:610-1110` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedCarousel.kt:83-125` +- Create: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibraryChromeInsetSourceTest.kt` + +**Interfaces:** +- Consumes: existing `LibrariesFloatingChrome`, `RecommendedTabContent`, `BrowseTabContent`, `CollectionsTabContent`, `FeaturedCarousel`, and `LocalBottomChromeInset`. +- Produces: one measured `Column` boundary in `LibrariesScreen`; a `FeaturedCarousel(topInset: Dp = 16.dp)` parameter that adds only content-local breathing room. + +- [ ] **Step 1: Write the failing structural regression tests** + +Create `LibraryChromeInsetSourceTest.kt` with repository-standard source loading: + +```kotlin +package org.prairieserver.prairie.android.ui.screens.libraries + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LibraryChromeInsetSourceTest { + private fun source(path: String): String { + val moduleRelative = File("src/androidMain/kotlin/$path") + val projectRelative = File("androidApp/src/androidMain/kotlin/$path") + return (moduleRelative.takeIf(File::exists) ?: projectRelative).readText() + } + + private val libraries = source( + "org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt", + ) + private val carousel = source( + "org/prairieserver/prairie/android/ui/screens/home/FeaturedCarousel.kt", + ) + + @Test + fun sharedChromeOwnsReservedSpaceBeforeEveryLibraryTab() { + val chrome = libraries.indexOf("LibrariesFloatingChrome(") + val viewport = libraries.indexOf("LibraryContentViewport(") + assertTrue(chrome >= 0) + assertTrue(viewport > chrome) + assertTrue(libraries.contains("Modifier.weight(1f).clipToBounds()")) + } + + @Test + fun tabsDoNotCarryOverlayClearanceRunways() { + assertFalse(libraries.contains("LibrariesChromeContentHeight")) + assertFalse(libraries.contains("extraTopInset = 50.dp")) + assertFalse(libraries.contains(".windowInsetsPadding(WindowInsets.statusBars)")) + assertFalse(carousel.contains("WindowInsets.statusBars")) + assertTrue(carousel.contains("topInset: androidx.compose.ui.unit.Dp = 16.dp")) + } +} +``` + +- [ ] **Step 2: Run the source test and verify RED** + +Run: + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests org.prairieserver.prairie.android.ui.screens.libraries.LibraryChromeInsetSourceTest \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: FAIL because `LibraryContentViewport`, the reserved weighted/clipped viewport, and the simplified carousel inset do not exist; overlay-clearance constants remain. + +- [ ] **Step 3: Put the chrome before a shared clipped viewport** + +In `LibrariesScreen`, retain backdrop drawing in the root `Box`, then render: + +```kotlin +Column(modifier = Modifier.fillMaxSize()) { + LibrariesFloatingChrome( + scrimProgress = chromeScrimProgress, + selectedLibrary = selectedLibrary, + canSwitch = state.libraries.size > 1, + activeProfile = activeProfile, + selectedTab = state.selectedTab, + onLibrarySelectorClick = onLibrarySelectorClick, + onTabSelected = viewModel::selectTab, + onSearchClick = onSearchClick, + onRequestsClick = onRequestsClick, + onWatchTogetherClick = onWatchTogetherClick, + onSettingsClick = onSettingsClick, + onSwitchProfileClick = onSwitchProfileClick, + onSwitchServerClick = onSwitchServerClick, + onSignOutClick = onSignOutClick, + ) + + LibraryContentViewport( + modifier = Modifier.weight(1f).clipToBounds(), + ) { + when { + state.isLoadingLibraries && state.libraries.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + state.librariesError != null && state.libraries.isEmpty() -> { + ErrorView( + message = state.librariesError ?: "Failed to load libraries", + onRetry = viewModel::refresh, + modifier = Modifier.fillMaxSize(), + ) + } + selectedLibrary == null -> { + EmptyStateView( + title = "No libraries available", + subtitle = "Libraries visible to this profile will show up here", + icon = Icons.Default.VideoLibrary, + modifier = Modifier.fillMaxSize(), + ) + } + state.selectedTab == LibrariesSubtab.Recommended -> { + RecommendedTabContent( + state = state, + listState = recommendedListState, + onItemClick = onItemClick, + onPlayClick = onPlayClick, + onRetry = viewModel::retryCurrentTab, + onActiveBackdropChange = { url, thumbhash -> + heroBackdropUrl = url + heroBackdropThumbhash = thumbhash + }, + ) + } + state.selectedTab == LibrariesSubtab.Browse -> { + BrowseTabContent( + state = state, + onItemClick = onItemClick, + onRetry = viewModel::retryCurrentTab, + onLoadMore = viewModel::loadMoreCatalog, + onSortChanged = viewModel::selectBrowseSort, + onNamePrefixChanged = viewModel::selectNamePrefix, + onDensityChanged = viewModel::selectViewDensity, + onApplyFilter = viewModel::applyFilterState, + onSetPreserve = viewModel::setPreserveFilters, + ) + } + else -> { + CollectionsTabContent( + state = state, + onCollectionClick = { collectionId -> + state.selectedLibraryId?.let { libraryId -> + onCollectionClick(collectionId, libraryId) + } + }, + onRetry = viewModel::retryCurrentTab, + ) + } + } + } +} +``` + +Add the focused wrapper next to `LibrariesScreen`: + +```kotlin +@Composable +private fun LibraryContentViewport( + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + Box( + modifier = modifier.fillMaxWidth(), + content = content, + ) +} +``` + +Import `androidx.compose.ui.draw.clipToBounds`. + +- [ ] **Step 4: Remove duplicate top-clearance padding** + +Delete `LibrariesChromeContentHeight`. In Recommended loading/error/empty +states, remove `.padding(top = LibrariesChromeContentHeight)` and +`.windowInsetsPadding(WindowInsets.statusBars)`. Remove the `no-featured` +status-bar/chrome spacer and replace it with: + +```kotlin +item(key = "no-featured") { + Spacer(modifier = Modifier.height(16.dp)) +} +``` + +In Browse, change the outer modifier to: + +```kotlin +modifier = Modifier.fillMaxSize() +``` + +In Collections, remove `contentTopPadding` and every +`.padding(top = contentTopPadding)` while retaining existing grid/content +padding and `LocalBottomChromeInset`. + +- [ ] **Step 5: Simplify the carousel's top inset** + +In `FeaturedCarousel`, replace `extraTopInset` and the system-inset calculation: + +```kotlin +topInset: androidx.compose.ui.unit.Dp = 16.dp, +``` + +and: + +```kotlin +Spacer(modifier = Modifier.height(topInset)) +``` + +Remove the unused `WindowInsets`, `asPaddingValues`, and +`calculateTopPadding` imports. The only caller uses the 16dp default. + +- [ ] **Step 6: Run the focused test and existing phone metadata/menu regressions** + +Run: + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests org.prairieserver.prairie.android.ui.screens.libraries.LibraryChromeInsetSourceTest \ + --tests org.prairieserver.prairie.android.ui.screens.home.FeaturedHeroMetadataTest \ + --tests org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherMenuEntrySourceTest \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: BUILD SUCCESSFUL; all selected tests pass. + +- [ ] **Step 7: Inspect the scoped diff and commit** + +Run: + +```bash +git diff --check +git diff -- \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedCarousel.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibraryChromeInsetSourceTest.kt +``` + +Commit: + +```bash +git add \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedCarousel.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibraryChromeInsetSourceTest.kt +git commit -m "fix(android): keep library content below chrome" +``` + +### Task 2: Verify the integrated branch and publish tester artifacts + +**Files:** +- Verify only: all files changed by `origin/main...HEAD` + +**Interfaces:** +- Consumes: Task 1's fixed library viewport plus existing TV focus, editorial hero, and identity-scoped request fixes. +- Produces: independently reviewed branch, green phone/TV gates, signed universal tester APKs, and updated PR #126. + +- [ ] **Step 1: Run supply-chain policy** + +Run: + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +``` + +Expected: self-tests pass and both commands exit 0. + +- [ ] **Step 2: Run the full phone/TV unit and release gate** + +Run: + +```bash +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + :androidApp:assembleRelease \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: BUILD SUCCESSFUL; no unit-test failures; both universal release APKs exist. + +- [ ] **Step 3: Perform emulator-only visual validation when available** + +Use only a dedicated phone emulator selected explicitly through the local test +harness. Install the debug build serial-specifically, launch the +phone app, and verify Recommended, Browse, and Collections content stops at the +fixed chrome boundary before and after scrolling. Open/close the profile menu +and verify the list position does not change. If the dedicated emulator is not +online, record the limitation; do not touch a physical device. + +- [ ] **Step 4: Obtain independent whole-branch review** + +Provide the reviewer `origin/main...HEAD`, the approved specs, focused/full test +results, and emulator evidence or limitation. Require explicit approval or fix +each verified finding test-first before publication. + +- [ ] **Step 5: Verify and copy signed universal APKs** + +Verify package/version, universal ABIs, v2 signature, size, and SHA-256 for: + +```text +androidApp/build/outputs/apk/release/androidApp-universal-release.apk +androidTvApp/build/outputs/apk/release/androidTvApp-universal-release.apk +``` + +Copy them without overwriting existing files into the tester-selected release +artifact destination outside the repository, using filenames containing +`0.3.11`, `FocusHeroLibraryInset`, and the final short commit. + +- [ ] **Step 6: Final hygiene and PR update** + +Run: + +```bash +git diff --check origin/main...HEAD +git status --short --branch +git log --oneline origin/main..HEAD +``` + +Push `fix/tv-for-you-cold-navigation`, update PR #126 with the final test, +review, emulator, and artifact evidence, and leave it open and unmerged. diff --git a/docs/superpowers/plans/2026-07-28-android-player-system-brightness.md b/docs/superpowers/plans/2026-07-28-android-player-system-brightness.md new file mode 100644 index 000000000..7ac489f38 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-android-player-system-brightness.md @@ -0,0 +1,188 @@ +# Android Player System Brightness Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the Android phone player's window-brightness override while preserving every other player gesture and keep-screen-awake behavior. + +**Architecture:** Extract the vertical-drag start-zone decision into a small pure classifier used by `PlayerGestureHandler`. The classifier maps the left edge to no action, the right edge to volume, and the center to dismissal; the obsolete brightness mode and window mutation are then removed. + +**Tech Stack:** Kotlin, Jetpack Compose pointer input, Android `AudioManager`, Kotlin/JUnit unit tests, Gradle. + +## Global Constraints + +- Android phone only; do not change Android TV. +- Never write `WindowManager.LayoutParams.screenBrightness`. +- Preserve right-edge volume, center swipe-down dismissal, double-tap seeking, pinch aspect changes, control toggling, temporary fast-forward, and `FLAG_KEEP_SCREEN_ON`. +- A left-edge vertical drag is a no-op and must not become a dismiss candidate. +- Do not add permissions or mutate Android system brightness settings. +- Do not install or modify the Shield. + +--- + +### Task 1: Remove mobile window-brightness ownership + +**Files:** +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerGestureHandler.kt` +- Create: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerVerticalDragModeTest.kt` + +**Interfaces:** +- Produces: `internal enum class VerticalDragMode { None, Volume, DismissCandidate }`. +- Produces: `internal fun verticalDragMode(startX: Float, width: Float, edgeZonePx: Float): VerticalDragMode`. +- Preserves: `adjustVolume(AudioManager, Float)` and every public `PlayerGestureHandler` parameter. + +- [ ] **Step 1: Write the failing classifier tests** + +```kotlin +class PlayerVerticalDragModeTest { + @Test + fun `left edge leaves system brightness authoritative`() { + assertEquals( + VerticalDragMode.None, + verticalDragMode(startX = 40f, width = 1_000f, edgeZonePx = 88f), + ) + } + + @Test + fun `right edge retains volume routing`() { + assertEquals( + VerticalDragMode.Volume, + verticalDragMode(startX = 950f, width = 1_000f, edgeZonePx = 88f), + ) + } + + @Test + fun `center retains dismiss routing`() { + assertEquals( + VerticalDragMode.DismissCandidate, + verticalDragMode(startX = 500f, width = 1_000f, edgeZonePx = 88f), + ) + } +} +``` + +- [ ] **Step 2: Run RED** + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests '*PlayerVerticalDragModeTest*' \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: test compilation fails because the production classifier and visible +mode contract do not exist yet. + +- [ ] **Step 3: Implement the minimal routing change** + +In `PlayerGestureHandler.kt`, add: + +```kotlin +internal enum class VerticalDragMode { None, Volume, DismissCandidate } + +internal fun verticalDragMode( + startX: Float, + width: Float, + edgeZonePx: Float, +): VerticalDragMode = when { + startX < edgeZonePx -> VerticalDragMode.None + startX > width - edgeZonePx -> VerticalDragMode.Volume + else -> VerticalDragMode.DismissCandidate +} +``` + +Use this function from `onDragStart`. Remove `VerticalDragMode.Brightness`, +`adjustBrightness`, and the unused `Window`/`WindowManager` imports. Keep +`LocalContext` because `AudioManager` still needs it. Update the gesture +documentation to say the left edge is reserved and does not alter brightness. + +- [ ] **Step 4: Run GREEN** + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests '*PlayerVerticalDragModeTest*' \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: all three routing tests pass. + +- [ ] **Step 5: Run focused player regressions** + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests '*Player*Gesture*' \ + --tests '*PlayerPinchGravity*' \ + --tests '*MobilePlayerLifecycle*' \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: all selected tests pass. + +- [ ] **Step 6: Build the phone release** + +```bash +./gradlew :androidApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --max-workers=2 --no-daemon +``` + +Expected: `BUILD SUCCESSFUL` and release APK outputs exist. + +- [ ] **Step 7: Verify the final diff and commit** + +```bash +git diff --check +git diff --stat +git status --short +``` + +Confirm that no Android TV or system-settings code changed, then commit: + +```bash +git add \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerGestureHandler.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerVerticalDragModeTest.kt +git commit -m "fix(player): leave phone brightness system-managed" +``` + +### Task 2: Pixel validation and PR update + +**Files:** +- Modify only if a confirmed regression requires a test-first correction: Task 1 files. +- Update: PR #127 description/check evidence without merging. + +**Interfaces:** +- Consumes: the Task 1 release APK. +- Produces: Pixel evidence that system brightness remains authoritative and preserved gestures still operate. + +- [ ] **Step 1: Verify safe Pixel upgrade compatibility** + +On serial `58211FDCQ000CU`, compare candidate and installed package, version, +and signing certificate. Abort without uninstall, clear-data, or downgrade if +they differ incompatibly. + +- [ ] **Step 2: Install and launch on the Pixel** + +```bash +adb -s 58211FDCQ000CU install -r \ + androidApp/build/outputs/apk/release/androidApp-arm64-v8a-release.apk +adb -s 58211FDCQ000CU shell am start \ + -n org.prairieserver.prairie/.android.MainActivity +``` + +- [ ] **Step 3: Validate behavior** + +During video playback, verify: + +1. Android's brightness slider changes display brightness before and after a + left-edge vertical drag. +2. A left-edge drag does not dismiss playback. +3. A right-edge drag still changes media volume. +4. A center downward drag still dismisses after playback is established. +5. Double-tap seek and pinch aspect-mode changes still work. +6. Playback still prevents the display from sleeping while playing/buffering. +7. Fresh app logs contain no fatal exception, crash, or ANR. + +- [ ] **Step 4: Push and update PR #127** + +Push `fix/subtitle-aspect-recenter`, record focused test/release/Pixel evidence +on PR #127, and wait for hosted Unit tests and CodeRabbit. Do not merge without +fresh user authorization. diff --git a/docs/superpowers/plans/2026-07-28-android-release-audit-fixes.md b/docs/superpowers/plans/2026-07-28-android-release-audit-fixes.md new file mode 100644 index 000000000..6c869e854 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-android-release-audit-fixes.md @@ -0,0 +1,174 @@ +# Android Release Audit Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate the three Important Android phone release bugs found by the final audit: stale Libraries responses, Browse content behind bottom chrome, and cross-identity cache attribution. + +**Architecture:** Libraries request families will use generation tokens owned by `LibrariesViewModel`, allowing superseded asynchronous completions to be discarded without changing repository APIs. `CatalogGrid` will accept the already-measured bottom chrome inset and reserve it for both grid content and the alphabet rail. Offline cache writes will preserve request-time identity ownership by rejecting writes after the shared identity generation changes. + +**Tech Stack:** Kotlin, coroutines/StateFlow, Jetpack Compose, Room-backed Android catalog cache, Kotlin test/coroutines-test, Gradle. + +## Global Constraints + +- Android phone and shared Android code only; no server/API/schema/protocol changes. +- Preserve existing successful response, offline fallback, paging, and identity-transition behavior. +- Tests must deterministically complete deferred requests out of order; no sleeps or widened timeouts. +- Physical devices remain excluded. +- Every production correction must be preceded by a failing regression. + +--- + +### Task 1: Make Libraries Results Current and Keep Browse Above Bottom Chrome + +**Files:** +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/CatalogGrid.kt` +- Test: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesViewModelTest.kt` +- Test: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibraryChromeInsetSourceTest.kt` + +**Interfaces:** +- Consumes: `LocalBottomChromeInset.current`, the existing `LibrariesUiState` selected library/query fields, and existing repository suspend functions. +- Produces: monotonically increasing Recommended, Browse, and Collections request generations; `CatalogGrid(..., bottomContentInset: Dp = 0.dp)` whose grid and alphabet rail stay above that inset. + +- [ ] **Step 1: Write deferred-response regressions** + +Add deterministic tests that start request A, change library or Browse query state, start request B, complete B first, then A, and assert the final rows/grid still belong to B. Cover Recommended, Browse sort/filter, and Collections. + +- [ ] **Step 2: Verify the request regressions fail** + +Run: + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests org.prairieserver.prairie.android.ui.screens.libraries.LibrariesViewModelTest \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: the reverse-completion assertions fail because A overwrites B. + +- [ ] **Step 3: Implement request generations** + +Keep one counter per request family: + +```kotlin +private var recommendedRequestGeneration = 0L +private var catalogRequestGeneration = 0L +private var collectionsRequestGeneration = 0L +``` + +Increment and capture the family generation before launching work. Before every success/error/loading completion write, require both the captured generation and the captured library/query identity to remain current. Superseded requests may finish, but must not mutate `uiState`. + +- [ ] **Step 4: Write the bottom-inset regression** + +Extend `LibraryChromeInsetSourceTest` to require `BrowseTabContent` to pass `LocalBottomChromeInset.current` into `CatalogGrid`, and require `CatalogGrid` to expose and consume `bottomContentInset` in both its scroll padding and alphabet-rail bounds. + +- [ ] **Step 5: Verify the inset regression fails** + +Run: + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests org.prairieserver.prairie.android.ui.screens.libraries.LibraryChromeInsetSourceTest \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: failure because `CatalogGrid` currently hard-codes 8dp bottom padding and the full-height rail. + +- [ ] **Step 6: Implement the measured bottom inset** + +Add: + +```kotlin +bottomContentInset: Dp = 0.dp +``` + +to `CatalogGrid`. Add it to grid/list bottom `contentPadding`, and constrain/pad the alphabet rail so its interactive range ends above the same inset. Pass `LocalBottomChromeInset.current` from Libraries Browse; standalone callers retain the zero default. + +- [ ] **Step 7: Run focused GREEN verification and commit** + +Run both focused test classes and relevant Android compilation. Commit only Task 1 files with: + +```bash +git commit -m "fix(android): keep library results and browse chrome current" +``` + +### Task 2: Preserve Request-Time Identity for Offline Cache Writes + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/cache/RoomCatalogCacheRepository.kt` +- Modify only if required by the narrow ownership boundary: `shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/CatalogRepository.kt` +- Modify only if required by the narrow ownership boundary: `shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SectionRepository.kt` +- Test: existing Room/cache repository Android unit tests and `shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/CatalogRepositoryDetailCacheTest.kt` + +**Interfaces:** +- Consumes: `IdentityTransitionBarrier.generation` and the existing cache snapshot/provider. +- Produces: cache writes that are accepted only when the request-time identity generation is still current; reads and offline fallback remain unchanged. + +- [ ] **Step 1: Write the delayed identity-switch regression** + +Create a deferred API response under identity A, switch the barrier/snapshot to B, complete A, then assert A's response is not written or readable as B. Cover item detail and one section/catalog path that exercises the shared ownership seam. + +- [ ] **Step 2: Verify the ownership regression fails** + +Run the exact new cache/repository tests with: + +```bash +./gradlew :shared:testDebugUnitTest :android-shared:testDebugUnitTest \ + --tests '*CatalogRepositoryDetailCacheTest*' \ + --tests '*RoomCatalogCacheRepository*' \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: failure showing the A response attributed to B. + +- [ ] **Step 3: Implement generation-validated writes** + +Capture `IdentityTransitionBarrier.generation` before each network-backed cacheable request. At completion, call the existing cache write only when the captured generation still equals the current generation. Do not synthesize a new identity from completion-time state and do not alter offline-read fallback semantics. + +- [ ] **Step 4: Run focused GREEN verification and commit** + +Run the new regressions plus neighboring cache/repository tests. Commit only Task 2 files with: + +```bash +git commit -m "fix(shared): keep cache writes identity scoped" +``` + +### Task 3: Integrate and Requalify the Release + +**Files:** +- Verify only: all files changed by Tasks 1 and 2. + +**Interfaces:** +- Consumes: both reviewed fix commits. +- Produces: one clean branch with green supply-chain, phone/TV unit, and phone/TV release gates. + +- [ ] **Step 0: Preserve request-time ownership for Home cache writes** + +Add a Home-cache write lease using the shared identity generation, propagate it +from the section request through both `StartupWarmup` and `HomeViewModel`, and +make `RoomHomeCacheRepository` reject stale leases before and after resolving +the identity snapshot. First add a deterministic delayed A→B regression that +proves A's Home sections cannot be stored or read as B. Preserve existing +offline Home reads and successful same-identity warmup behavior. + +- [ ] **Step 1: Review each task diff independently** + +Require explicit spec-compliance and code-quality approval; fix every Critical, Important, or Minor release finding before continuing. + +- [ ] **Step 2: Run the exact release gate** + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + :androidApp:assembleRelease \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +- [ ] **Step 3: Perform whole-branch review and artifact verification** + +Require a clean `origin/main...HEAD` review, verify universal APK package/version/ABIs/v2 signature/size/SHA-256, copy final-hash artifacts without overwrite, then push and update PR #126 without merging. diff --git a/docs/superpowers/plans/2026-07-28-android-subtitle-aspect-reconciliation-and-phone-sizing.md b/docs/superpowers/plans/2026-07-28-android-subtitle-aspect-reconciliation-and-phone-sizing.md new file mode 100644 index 000000000..87cc0c1b8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-android-subtitle-aspect-reconciliation-and-phone-sizing.md @@ -0,0 +1,532 @@ +# Android Subtitle Aspect Reconciliation and Phone Sizing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate stale/clipped subtitle geometry after Android aspect changes and make all phone subtitle presets 1.125× larger without changing TV sizing. + +**Architecture:** `SubtitleManager` remains the shared owner of Media3 and libass subtitle presentation. A fixed phone/television presentation class selects the font fraction, while `SubtitleVideoRectSync` uses generation-bound post-layout snapshot verification to request at most one corrective pre-draw pass when an aspect change exposes stale `exo_content_frame` bounds. + +**Tech Stack:** Kotlin Multiplatform, Android Media3 `PlayerView`/`SubtitleView`, Compose `AndroidView`, Koin, Robolectric, Gradle. + +## Global Constraints + +- Preserve subtitle selection, cue styling, authored positioning, libass/ASS, bitmap subtitle, letterbox, and title-safe behavior. +- Do not change subtitle tracks, server subtitle processing, playback protocols, preset names, or persisted subtitle appearance schema. +- Phone fractions are exactly Small `22.5 / 720`, Medium `29.25 / 720`, Large `36 / 720`, XLarge `45 / 720`, and XXLarge `54 / 720`. +- Television fractions remain Small `20 / 720`, Medium `26 / 720`, Large `32 / 720`, XLarge `40 / 720`, and XXLarge `48 / 720`. +- Reconciliation is bounded to two post-layout applications per explicit sync generation, coalesces repeated requests, and cancels all pending work on detach/dispose. +- Use Media3's measured `exo_content_frame`; do not duplicate Media3's aspect-ratio algorithm. +- Do not install or modify the Shield. Physical validation is limited to Pixel serial `58211FDCQ000CU`. + +--- + +### Task 1: Phone-only subtitle preset scaling + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt:40-46,334-342` +- Modify: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt:35-55` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt:219-222` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/di/AndroidTvModule.kt:142-146` +- Test: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleAspectSyncWiringTest.kt` +- Test: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAspectSyncWiringTest.kt` + +**Interfaces:** +- Produces: `enum class AndroidSubtitlePresentation { Phone, Television }`. +- Produces: `SubtitleManager(libassBridge: LibassBridge? = null, presentation: AndroidSubtitlePresentation = AndroidSubtitlePresentation.Television)`. +- Preserves: all existing `SubtitleManager()` test and utility construction as television-scale compatibility. + +- [ ] **Step 1: Write failing fraction tests** + +Replace the single reflected web-scale test with explicit phone and television assertions: + +```kotlin +@Test +fun phoneSubtitleTextFractionsUseApprovedPhoneScale() { + val manager = SubtitleManager( + presentation = AndroidSubtitlePresentation.Phone, + ) + assertEquals(22.5f / 720f, fractionalSize(manager, SubtitleFontSizePreset.Small)) + assertEquals(29.25f / 720f, fractionalSize(manager, SubtitleFontSizePreset.Medium)) + assertEquals(36f / 720f, fractionalSize(manager, SubtitleFontSizePreset.Large)) + assertEquals(45f / 720f, fractionalSize(manager, SubtitleFontSizePreset.XLarge)) + assertEquals(54f / 720f, fractionalSize(manager, SubtitleFontSizePreset.XXLarge)) +} + +@Test +fun televisionSubtitleTextFractionsPreserveExistingScale() { + val manager = SubtitleManager( + presentation = AndroidSubtitlePresentation.Television, + ) + assertEquals(20f / 720f, fractionalSize(manager, SubtitleFontSizePreset.Small)) + assertEquals(26f / 720f, fractionalSize(manager, SubtitleFontSizePreset.Medium)) + assertEquals(32f / 720f, fractionalSize(manager, SubtitleFontSizePreset.Large)) + assertEquals(40f / 720f, fractionalSize(manager, SubtitleFontSizePreset.XLarge)) + assertEquals(48f / 720f, fractionalSize(manager, SubtitleFontSizePreset.XXLarge)) +} +``` + +Update the existing phone/TV source-wiring contract tests to require the named +`presentation` argument with `Phone` and `Television`, respectively. The +production change that makes these tests pass is explicit DI selection plus +the presentation-aware conversion. + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: + +```bash +./gradlew \ + :android-shared:testDebugUnitTest \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*SubtitleManagerAppearanceTest*' \ + --tests '*SubtitleAspectSyncWiringTest*' \ + --tests '*TvSubtitleAspectSyncWiringTest*' \ + --max-workers=2 --no-daemon +``` + +Expected: compilation/assertion failures because +`AndroidSubtitlePresentation` and the explicit DI arguments do not exist and +phone fractions still equal television fractions. + +- [ ] **Step 3: Implement the presentation-aware conversion** + +Add the enum next to `SubtitleManager`, retain television as the default for +existing shared call sites, and select the numerator table without changing +the persisted `SubtitleFontSizePreset`: + +```kotlin +enum class AndroidSubtitlePresentation { + Phone, + Television, +} + +class SubtitleManager( + private val libassBridge: LibassBridge? = null, + private val presentation: AndroidSubtitlePresentation = + AndroidSubtitlePresentation.Television, +) { + private fun fractionalSizeFor(preset: SubtitleFontSizePreset): Float { + val numerator = when (presentation) { + AndroidSubtitlePresentation.Phone -> when (preset) { + SubtitleFontSizePreset.Small -> 22.5f + SubtitleFontSizePreset.Medium -> 29.25f + SubtitleFontSizePreset.Large -> 36f + SubtitleFontSizePreset.XLarge -> 45f + SubtitleFontSizePreset.XXLarge -> 54f + } + AndroidSubtitlePresentation.Television -> when (preset) { + SubtitleFontSizePreset.Small -> 20f + SubtitleFontSizePreset.Medium -> 26f + SubtitleFontSizePreset.Large -> 32f + SubtitleFontSizePreset.XLarge -> 40f + SubtitleFontSizePreset.XXLarge -> 48f + } + } + return numerator / 720f + } +} +``` + +Construct the phone singleton with +`presentation = AndroidSubtitlePresentation.Phone` and TV with +`presentation = AndroidSubtitlePresentation.Television`; use named arguments +for both constructor parameters. + +- [ ] **Step 4: Run the focused tests and verify GREEN** + +Run the Step 2 command. Expected: all selected tests pass with no compilation +or assertion failure. + +- [ ] **Step 5: Commit the sizing change** + +```bash +git add \ + android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt \ + android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/di/AndroidTvModule.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleAspectSyncWiringTest.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAspectSyncWiringTest.kt +git commit -m "fix(subtitles): scale phone caption presets" +``` + +### Task 2: Bounded stale-frame convergence + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt:583-768` +- Modify: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt:360-560` + +**Interfaces:** +- Consumes: existing `SubtitleVideoRectSync.updateAndReconcileAfterLayout()`. +- Produces: an internal immutable content-frame/resize snapshot used only to decide whether one corrective pre-draw is required. +- Preserves: `syncSubtitleVideoBounds(PlayerView)`, layout-listener behavior, and all public subtitle APIs. + +- [ ] **Step 1: Add a production-shaped RED transition test** + +Extend `MountedSubtitleCanvas` with a method that deliberately dispatches the +first pre-draw while the old content-frame bounds are still mounted, changes +the frame, drains the posted snapshot verification, then dispatches the +corrective pre-draw: + +```kotlin +fun transitionAfterEarlyPreDraw(resizeMode: Int, finalFrame: FrameBounds) { + schedule(resizeMode) + playerView.viewTreeObserver.dispatchOnPreDraw() + contentFrame.layout( + finalFrame.left, + finalFrame.top, + finalFrame.right, + finalFrame.bottom, + ) + Shadows.shadowOf(Looper.getMainLooper()).idle() + playerView.viewTreeObserver.dispatchOnPreDraw() +} +``` + +Add: + +```kotlin +@Test +fun mountedCanvasCorrectsFillToFitWhenFirstPreDrawSeesCroppedFrame() { + val canvas = MountedSubtitleCanvas() + canvas.transition( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + frame = FrameBounds(-120, -64, 2040, 1080), + ) + + canvas.transitionAfterEarlyPreDraw( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT, + finalFrame = FrameBounds(240, 0, 1680, 1016), + ) + + assertEquals(SubtitleVideoRect(0, 0, 1440, 1016), canvas.subtitleRect()) +} +``` + +The production change that makes this test pass is retaining the explicit-sync +generation long enough to notice that the frame snapshot changed after the +first pre-draw and scheduling exactly one corrective pass. + +- [ ] **Step 2: Run the single test and verify RED** + +Run: + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests '*SubtitleManagerAppearanceTest.mountedCanvasCorrectsFillToFitWhenFirstPreDrawSeesCroppedFrame' \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: FAIL because the existing one-shot pre-draw listener is removed +before the final Fit frame is mounted, leaving the Zoom-derived top/left +offset. + +- [ ] **Step 3: Add latest-generation and detach RED tests** + +Add tests that: + +```kotlin +@Test +fun rapidEarlyTransitionsApplyOnlyLatestMode() { + val canvas = MountedSubtitleCanvas() + canvas.schedule(AspectRatioFrameLayout.RESIZE_MODE_ZOOM) + canvas.schedule(AspectRatioFrameLayout.RESIZE_MODE_FILL) + canvas.schedule(AspectRatioFrameLayout.RESIZE_MODE_FIT) + canvas.dispatchEarlyPreDrawThenMount(FrameBounds(240, 0, 1680, 1016)) + assertEquals(SubtitleVideoRect(0, 0, 1440, 1016), canvas.subtitleRect()) + assertEquals(2, canvas.reconciliationCount) +} + +@Test +fun detachCancelsPostedSnapshotVerification() { + val canvas = MountedSubtitleCanvas() + canvas.schedule(AspectRatioFrameLayout.RESIZE_MODE_ZOOM) + canvas.dispatchPreDraw() + canvas.detach() + canvas.mountFrameAndDrain(FrameBounds(-120, -64, 2040, 1080)) + assertEquals(1, canvas.reconciliationCount) +} +``` + +Expose `reconciliationCount` through the existing +`postLayoutReconciliationObserver`; keep all harness helpers test-only. + +- [ ] **Step 4: Run the three tests and verify RED** + +Run: + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests '*SubtitleManagerAppearanceTest.mountedCanvasCorrectsFillToFitWhenFirstPreDrawSeesCroppedFrame' \ + --tests '*SubtitleManagerAppearanceTest.rapidEarlyTransitionsApplyOnlyLatestMode' \ + --tests '*SubtitleManagerAppearanceTest.detachCancelsPostedSnapshotVerification' \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: the Fill-to-Fit test remains red and the new lifecycle/count +assertions fail because no generation-bound snapshot verification exists. + +- [ ] **Step 5: Implement bounded snapshot verification** + +Inside `SubtitleVideoRectSync`, add: + +```kotlin +private data class LayoutSnapshot( + val resizeMode: Int, + val playerWidth: Int, + val playerHeight: Int, + val frameLeft: Int, + val frameTop: Int, + val frameWidth: Int, + val frameHeight: Int, +) + +private var reconciliationGeneration = 0L +private var pendingVerification: Runnable? = null +private var appliedPasses = 0 +``` + +`updateAndReconcileAfterLayout()` increments the generation only when creating +a new explicit reconciliation request, resets `appliedPasses`, coalesces the +single pending pre-draw, and captures no mutable view geometry. + +After the pre-draw calls `update()`, capture the snapshot actually applied, +increment `appliedPasses`, and post one main-thread verification runnable. The +runnable must: + +```kotlin +if ( + !isDisposed && + generation == reconciliationGeneration && + appliedPasses < 2 && + currentSnapshot(playerView) != appliedSnapshot +) { + schedulePreDrawFor(generation) +} +``` + +The second application does not post another correction. `dispose()` removes +the pre-draw listener, removes the posted runnable with +`playerView.removeCallbacks`, increments/invalidates the generation, and keeps +the existing listener cleanup. The permanent content-frame layout listener +continues to handle genuine later layouts. + +- [ ] **Step 6: Run focused reconciliation tests and verify GREEN** + +Run: + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests '*SubtitleManagerAppearanceTest*' \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: all appearance, geometry, coalescing, rapid-transition, and detach +tests pass. `repeatedExplicitSyncsRunOnePostLayoutReconciliation` must remain +green for stable geometry. + +- [ ] **Step 7: Commit the reconciliation fix** + +```bash +git add \ + android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt \ + android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt +git commit -m "fix(subtitles): converge aspect bounds after layout" +``` + +### Task 3: Stabilize initial phone subtitle restore + +**Files:** +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt` +- Test: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapterTest.kt` +- Verify unchanged: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleRemountReselection.kt` +- Test: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/SubtitleRemountReselectionTest.kt` + +**Interfaces:** +- Consumes: `MobileSubtitleTransactionAdapter.reportMountedSelection(...)` and + the existing five-second mobile mount deadline. +- Produces: stable-snapshot evidence owned by the pending mobile mount + generation; the first non-empty miss remains pending, a changed snapshot + restarts settlement, and a repeated identical miss may fail. + +- [ ] **Step 1: Write the failing mobile transaction tests** + +Change the immediate-miss test so it reports one ready, non-empty catalog and +asserts that the pending local restore remains active with no failure. Add a +second test that reports the same key twice and asserts the existing failure, +plus a changed-key test that requires the changed key to repeat before failure. + +```kotlin +harness.adapter.reportMountedSelection( + identity = local, + selected = false, + snapshotKey = "intermediate", + settled = true, +) +assertEquals(local, harness.adapter.snapshot.localMountIdentity) +assertNull(harness.adapter.snapshot.failureMessage) + +harness.adapter.reportMountedSelection( + identity = local, + selected = false, + snapshotKey = "intermediate", + settled = true, +) +assertNull(harness.adapter.snapshot.localMountIdentity) +assertTrue(harness.adapter.snapshot.failureMessage?.contains("mount", true) == true) +``` + +- [ ] **Step 2: Run RED** + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests '*MobileSubtitleTransactionAdapterTest*' \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: the first-snapshot assertion fails because the adapter currently +calls `failLocalMount` immediately. + +- [ ] **Step 3: Implement generation-owned snapshot stabilization** + +Add one nullable last-miss snapshot key to +`MobileSubtitleTransactionAdapter`. For a non-selected result with +`settled=true`, record the first non-blank key; fail only when the same key is +reported again. A changed key replaces the candidate and remains provisional. +Clear the candidate from `invalidateLocalMount()` so content, identity, and +generation changes cannot inherit old evidence. + +In `PlayerScreen` keep the immediate `LaunchedEffect` mount attempt +provisional (`settled = false`). Track callbacks remain the source of settled +catalog evidence. Do not change the five-second timeout, successful-selection +path, persisted identity, or error copy. + +- [ ] **Step 4: Run GREEN and focused TV parity** + +```bash +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*MobileSubtitleTransactionAdapterTest*' \ + --tests '*SubtitleRemountReselectionTest*' \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: mobile first/changed/repeated snapshot tests pass, and TV's existing +first-snapshot stabilization tests remain green without TV production edits. + +- [ ] **Step 5: Commit** + +```bash +git add \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapterTest.kt +git commit -m "fix(subtitles): await stable tracks on playback restore" +``` + +### Task 4: Regression, release, and Pixel validation + +**Files:** +- Modify only if evidence requires a test correction: files from Tasks 1-3. +- Record verification in the PR description; do not add generated artifacts to git. + +**Interfaces:** +- Consumes: phone presentation scaling and bounded reconciliation from Tasks 1-2. +- Produces: verified phone/TV release artifacts and physical Pixel evidence. + +- [ ] **Step 1: Run focused shared/phone/TV tests uncached** + +```bash +./gradlew \ + :android-shared:testDebugUnitTest \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*Subtitle*' \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: all selected subtitle tests pass. + +- [ ] **Step 2: Run supply-chain verification** + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +``` + +Expected: both scripts exit zero without changing tracked files. + +- [ ] **Step 3: Run the full unit and release gates** + +```bash +./gradlew \ + testDebugUnitTest \ + :androidApp:assembleRelease \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --max-workers=2 --no-daemon +``` + +Expected: `BUILD SUCCESSFUL`; both release APK outputs exist. Do not install the +TV artifact. + +- [ ] **Step 4: Safely install the phone release on the Pixel** + +Verify serial, package, version, and signer compatibility first. Then use only: + +```bash +adb -s 58211FDCQ000CU install -r \ + androidApp/build/outputs/apk/release/androidApp-universal-release.apk +``` + +Abort without uninstalling, clearing data, downgrading, or changing settings if +the signer or version is incompatible. + +- [ ] **Step 5: Validate the reproduced matrix on Pixel** + +Using the already configured subtitle track: + +1. Play through at least three consecutive text cues in Fit. +2. Change Fit → Fill → Stretch → Fit, closing the sheet after each selection. +3. Repeat Fill → Fit rapidly three times. +4. Confirm each cue is horizontally centered, fully above the display bottom, + and visible on the first cue after each transition. +5. Confirm multi-line cues are not clipped. +6. Confirm the default Large phone size is visibly larger than the pre-fix + `32 / 720` build and that Small through XXLarge remain ordered. +7. Capture serial-scoped screenshots and fresh app-process logs; confirm no + fatal exception, ANR, subtitle parser error, or playback regression. + +- [ ] **Step 6: Request independent review** + +Provide the reviewer with the approved spec, this plan, commits from Tasks 1-2, +the focused/full gate outputs, and Pixel screenshots. Require explicit verdicts +on: + +- generation/coalescing correctness, +- detach and callback ownership, +- parent-local Media3 geometry, +- phone-only sizing and TV preservation, +- absence of server/protocol/persistence changes. + +Fix only evidenced findings test-first and rerun the smallest affected gate. + +- [ ] **Step 7: Final diff and branch verification** + +```bash +git diff --check origin/main...HEAD +git status --short +git log --oneline --decorate origin/main..HEAD +``` + +Expected: no whitespace errors, a clean worktree, and only the approved PR #127 +subtitle work plus its spec/plan/fix commits. + +- [ ] **Step 8: Push and update PR #127 without merging** + +Push `fix/subtitle-aspect-recenter`, update PR #127 with the new Pixel +reproduction and verification evidence, and wait for hosted checks and +CodeRabbit. Do not merge without fresh user authorization. diff --git a/docs/superpowers/plans/2026-07-28-android-tv-active-header-focus-editorial-hero.md b/docs/superpowers/plans/2026-07-28-android-tv-active-header-focus-editorial-hero.md new file mode 100644 index 000000000..7a14598bb --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-android-tv-active-header-focus-editorial-hero.md @@ -0,0 +1,943 @@ +# Android Active Header Focus and Editorial Hero Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make content-to-header navigation land on the active TV section and present ordered editorial browsing metadata on Android phone and TV. + +**Architecture:** Keep `TvMainShell` as the route-aware focus coordinator, pass its active root explicitly through `TvShellFocusState`, and let `TvTopMenuBar` apply the existing requester after composition. Keep TV hero transformation inside `TvMarqueeContent.from` and phone hero transformation in the small pure `featuredHeroMetadata` mapper, using only existing `SectionItem` data and leaving player/detail surfaces unchanged. + +**Tech Stack:** Kotlin 2.1, Jetpack Compose/Compose for TV, Compose focus and `FlowRow` layout APIs, Kotlin coroutines, Kotlin test/JUnit, Gradle. + +## Global Constraints + +- TV header-focus behavior is Android TV only. +- Editorial browsing-hero metadata applies to Android phone and TV. +- No server, API, database, payload, schema, or production-configuration changes. +- No player-overlay, playback-settings, item-detail, stream-selection, transcoding, or subtitle changes. +- No phone Home hero; phone scope is the existing Library Recommended featured carousel only. +- No Apple-client changes. +- Preserve the held-Up boundary: repeated Up stops on the first content row and a fresh Up enters the menu. +- Search receives content-to-menu focus only while Search is the active route. +- Technical resolution, HDR, and audio data remains available to other consumers but is not rendered in browsing heroes. +- Preserve synopsis, cast/air-date enrichment, artwork, cache-first loading, and crossfade behavior. +- Invalid, non-finite, zero, negative, blank, or unavailable metadata is omitted without placeholders, empty chips, or dangling separators. +- Phone metadata may wrap to at most two lines and must not overlap carousel actions or change the carousel page height. +- Shared hydration/navigation-performance changes already present in PR #126 are its accepted baseline and remain unchanged. +- Do not merge or deploy; update open PR #126 only after all required verification is green. + +--- + +## File Map + +- `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt` + derives the active root and supplies one explicit focus target to both Up and + Back paths. +- `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusState.kt` + carries the requested menu target without performing Compose focus itself. +- `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequest.kt` + provides the small frame-ordered focus application seam. +- `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuBar.kt` + resolves the active target to an existing `FocusRequester` and applies it + after composition. +- `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt` + builds browsing-hero editorial metadata from `SectionItem`. +- `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequestTest.kt` + proves frame ordering and request-result propagation. +- `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusStateTest.kt` + proves Up/Back retain the requested active root. +- `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt` + proves TV movie/episode metadata ordering, technical-badge removal, and + invalid-value omission. +- `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedCarousel.kt` + renders phone Library Recommended hero chips in a bounded two-line + `FlowRow`. +- `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadata.kt` + maps existing `SectionItem` fields to ordered, testable phone hero chips. +- `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadataTest.kt` + proves phone movie/episode ordering, generic-type removal, and invalid-value + omission. + +--- + +### Task 1: Make active-section header focus deterministic + +**Files:** +- Create: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequest.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequestTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt:466-470,719-735,802-815` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusState.kt:174-184,285-309` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuBar.kt:225-242` +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusStateTest.kt:90-125,245-280` + +**Interfaces:** +- Consumes: `TvTopMenuPanel.Root(TvRootDestination)`, `TvShellFocusState.requestMenuFocusIfAvailable(TvTopMenuPanel?, Boolean)`, and each existing top-menu `FocusRequester`. +- Produces: `internal suspend fun requestTopMenuFocusUntilApplied(awaitFrame: suspend () -> Unit, requestFocus: () -> Boolean)`. +- Produces: `TvShellFocusState.onBack(onTabRoot: Boolean, menuFocusTarget: TvTopMenuPanel? = null): TvShellBackAction`. +- Produces: one `selectedMenuFocusTarget: TvTopMenuPanel?` in `TvMainShell`, reused by content Up and Back. + +- [ ] **Step 1: Write the frame-ordering regression test** + +Create `TvTopMenuFocusRequestTest.kt`: + +```kotlin +package org.prairieserver.prairie.tv.ui.shell + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvTopMenuFocusRequestTest { + @Test + fun focusIsRequestedOnlyAfterTheTargetHasHadAFrameToCompose() = runTest { + val events = mutableListOf() + + requestTopMenuFocusUntilApplied( + awaitFrame = { events += "frame" }, + requestFocus = { events += "focus"; true }, + ) + + assertEquals(listOf("frame", "focus"), events) + } + + @Test + fun aTargetThatIsNotAttachedYetIsRetriedOnTheNextFrame() = runTest { + val events = mutableListOf() + var attempts = 0 + + requestTopMenuFocusUntilApplied( + awaitFrame = { events += "frame" }, + requestFocus = { + events += "focus" + attempts += 1 + attempts == 2 + }, + ) + + assertEquals(listOf("frame", "focus", "frame", "focus"), events) + } +} +``` + +- [ ] **Step 2: Add the active-root Back regression** + +Append to `TvShellFocusStateTest`: + +```kotlin +@Test +fun backFromRootContentRetainsTheActiveRootAsItsMenuTarget() { + val state = TvShellFocusState() + + assertEquals( + TvShellBackAction.MoveFocusToMenu, + state.onBack( + onTabRoot = true, + menuFocusTarget = moviesPanel, + ), + ) + + assertEquals(moviesPanel, state.menuFocusTarget) +} +``` + +- [ ] **Step 3: Run the focused tests and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests "org.prairieserver.prairie.tv.ui.shell.TvTopMenuFocusRequestTest" \ + --tests "org.prairieserver.prairie.tv.ui.shell.TvShellFocusStateTest" \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: compilation fails because `requestTopMenuFocusUntilApplied` and the +`menuFocusTarget` argument do not exist. + +- [ ] **Step 4: Implement the frame-ordered focus seam** + +Create `TvTopMenuFocusRequest.kt`: + +```kotlin +package org.prairieserver.prairie.tv.ui.shell + +internal suspend fun requestTopMenuFocusUntilApplied( + awaitFrame: suspend () -> Unit, + requestFocus: () -> Boolean, +) { + do { + awaitFrame() + } while (!requestFocus()) +} +``` + +- [ ] **Step 5: Carry the active target through the state holder** + +Change `TvShellFocusState.onBack` to: + +```kotlin +fun onBack( + onTabRoot: Boolean, + menuFocusTarget: TvTopMenuPanel? = null, +): TvShellBackAction { + val action = tvShellBackAction( + panelOpen = openPanel != null, + profileMenuOpen = profileMenuOpen, + menuFocused = isMenuFocused, + onTabRoot = onTabRoot, + ) + when (action) { + TvShellBackAction.ClosePanel -> closePanel(returnFocusToBar = true) + TvShellBackAction.CloseProfileMenu -> dismissProfileMenu() + TvShellBackAction.MoveFocusToMenu -> requestMenuFocus(menuFocusTarget) + TvShellBackAction.MenuBack, + TvShellBackAction.DelegateToNav -> Unit + } + return action +} +``` + +Do not alter the pure `tvShellBackAction` precedence. + +- [ ] **Step 6: Derive one route-aware target and reuse it** + +Immediately after `selectedRoot` in `TvMainShell`, add: + +```kotlin +val selectedMenuFocusTarget = selectedRoot?.let(TvTopMenuPanel::Root) +``` + +Pass it to Back: + +```kotlin +focusState.onBack( + onTabRoot = selectedRoot != null, + menuFocusTarget = selectedMenuFocusTarget, +) +``` + +Use it at both first-row Up handoffs: + +```kotlin +focusState.requestMenuFocusIfAvailable( + selectedMenuFocusTarget, + allowNullTarget = currentRoute == TvMainRoute.Search.route, +) +``` + +Leave `selectedMenuFocusTarget` null on Search. `TvTopMenuBar` must continue +using `isSearchActive` to select Search for that route; other secondary routes +must not silently select Home. + +- [ ] **Step 7: Apply the requester after composition and record the handled identity** + +In `TvTopMenuBar`, track `focusRequestIdentity` as +`focusRequest to focusRequestTarget`, then replace the immediate focus call +inside the `LaunchedEffect(focusRequest, focusRequestTarget, isFocusSuppressed)` +with: + +```kotlin +requestTopMenuFocusUntilApplied( + awaitFrame = { androidx.compose.runtime.withFrameNanos { } }, + isTargetCurrent = { + currentFocusRequestTarget == focusRequestTarget && + isTopMenuFocusTargetAvailable(focusRequestTarget, currentDestinations) + }, + requestFocus = { + runCatching { requester.requestFocus() }.getOrDefault(false) + }, +) +lastHandledFocusRequest = focusRequestIdentity +``` + +Move the existing `lastHandledFocusRequest = focusRequestIdentity` assignment +out of the pre-request path. The frame loop suspends rather than spins and is +cancelled automatically if the `LaunchedEffect` keys change. Keep the +explicit-target resolution and `dwellSuppressedButton` behavior unchanged. + +- [ ] **Step 8: Run focused tests and verify GREEN** + +Run the command from Step 3. + +Expected: both test classes pass with zero failures. + +- [ ] **Step 9: Run the existing Up-navigation regression** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests "org.prairieserver.prairie.tv.ui.components.TvSkylineUpNavigationTest" \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: all four held-Up/row-relocation tests pass unchanged. + +- [ ] **Step 10: Commit the focus correction** + +```bash +git add \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusState.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuBar.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequest.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusStateTest.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequestTest.kt +git commit -m "fix(tv): restore active header focus from content" +``` + +--- + +### Task 2: Replace stream badges with editorial hero metadata + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt:75-165` +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt:1-32` + +**Interfaces:** +- Consumes: `SectionItem.year`, `durationSeconds`, `ratingImdb`, `genres`, `contentRating`, `seriesTitle`, `seasonNumber`, `episodeNumber`, and existing `TvMarqueeEnrichment.detailLine`. +- Produces: `TvMarqueeContent.badges` containing only an optional uppercase content classification. +- Produces: ordered `TvMarqueeContent.metaParts`: movie `year → runtime → IMDb → genre`; episode `Sx Ey → episode title → runtime → IMDb`. + +- [ ] **Step 1: Replace quality-badge tests with movie editorial-metadata RED** + +Replace `TvFocusMarqueeModelTest` with: + +```kotlin +package org.prairieserver.prairie.tv.ui.components + +import org.prairieserver.prairie.model.catalog.OverlaySummary +import org.prairieserver.prairie.model.section.SectionItem +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvFocusMarqueeModelTest { + @Test + fun movieHeroPrioritizesEditorialMetadataAndOmitsStreamQuality() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "movie-1", + type = "movie", + title = "Arrival", + year = 2016, + genres = listOf("Science Fiction"), + ratingImdb = 7.9, + contentRating = "PG-13", + durationSeconds = 6_960.0, + overlaySummary = OverlaySummary( + resolution = "2160p", + hdr = "Dolby Vision", + audio = "TrueHD Atmos", + ), + ), + rowTitle = "Popular", + ) + + assertEquals(listOf("PG-13"), content.badges) + assertEquals( + listOf("2016", "1h 56m", "7.9", "Science Fiction"), + content.metaParts, + ) + } + + @Test + fun episodeHeroUsesSeriesTitleAndEditorialEpisodeMetadata() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "episode-1", + type = "episode", + title = "Long, Long Time", + seriesTitle = "The Last of Us", + seasonNumber = 1, + episodeNumber = 3, + ratingImdb = 8.6, + contentRating = "TV-MA", + durationSeconds = 4_560.0, + overlaySummary = OverlaySummary( + resolution = "1080p", + audio = "EAC3", + ), + ), + rowTitle = "Continue Watching", + ) + + assertEquals("The Last of Us", content.title) + assertEquals(listOf("TV-MA"), content.badges) + assertEquals( + listOf("S1 E3", "Long, Long Time", "1h 16m", "8.6"), + content.metaParts, + ) + } + + @Test + fun missingEditorialMetadataProducesNoEmptyTokensOrBadges() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "movie-2", + type = "movie", + title = "Untitled", + overlaySummary = OverlaySummary( + resolution = "2160p", + hdr = "HDR10", + audio = "Atmos", + ), + ), + rowTitle = "Recently Added", + ) + + assertEquals(emptyList(), content.badges) + assertEquals(emptyList(), content.metaParts) + } +} +``` + +- [ ] **Step 2: Run the model test and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests "org.prairieserver.prairie.tv.ui.components.TvFocusMarqueeModelTest" \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: the movie order differs, the episode lacks its rating, and technical +quality badges are still present. + +- [ ] **Step 3: Implement the approved metadata ordering** + +In `TvMarqueeContent.from`, build metadata exactly as follows: + +```kotlin +val meta = mutableListOf() +if (isEpisode) { + episodeToken(item.seasonNumber, item.episodeNumber)?.let(meta::add) + if (item.title.isNotBlank()) meta.add(item.title) + lengthText(item.durationSeconds)?.let(meta::add) + item.ratingImdb?.let { meta.add(formatRating(it)) } +} else { + if (item.year > 0) meta.add(item.year.toString()) + lengthText(item.durationSeconds)?.let(meta::add) + item.ratingImdb?.let { meta.add(formatRating(it)) } + item.genres.firstOrNull { it.isNotBlank() }?.let(meta::add) +} + +val badges = item.contentRating + ?.takeIf { it.isNotBlank() } + ?.uppercase(Locale.US) + ?.let(::listOf) + .orEmpty() +``` + +Delete `qualityBadges`, `dynamicRangeBadge`, `audioBadge`, and +`prettyResolution`; they have no remaining callers. Do not remove +`SectionItem.overlaySummary` or change shared models. + +- [ ] **Step 4: Preserve enrichment and rendering contracts** + +Verify by inspection that `TvMarqueeEnrichment.from` still emits its existing +air-date/cast `detailLine`, `TvMarqueeContent.withEnrichment` still preserves +the content identity, and `TvFocusMarquee` still omits the badge/meta row when +both lists are empty. Make no changes to those paths. + +- [ ] **Step 5: Run the focused model test and verify GREEN** + +Run the command from Step 2. + +Expected: all three tests pass with zero failures. + +- [ ] **Step 6: Commit the editorial hero correction** + +```bash +git add \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt +git commit -m "fix(tv): show editorial metadata in browse heroes" +``` + +--- + +### Task 3: Add phone parity and reject invalid hero metadata + +**Files:** +- Create: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadata.kt` +- Create: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadataTest.kt` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedCarousel.kt:1-65,275-375,467-505` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt:80-100,185-215` +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt` + +**Interfaces:** +- Consumes: existing `SectionItem` editorial fields only. +- Produces: `internal enum class FeaturedHeroMetadataKind { Plain, Rating, Classification }`. +- Produces: `internal data class FeaturedHeroMetadataChip(val label: String, val kind: FeaturedHeroMetadataKind = FeaturedHeroMetadataKind.Plain)`. +- Produces: `internal fun featuredHeroMetadata(item: SectionItem): List`. +- Preserves: TV `TvMarqueeContent` ordering from Task 2 while filtering invalid ratings and durations. + +- [ ] **Step 1: Write phone movie and episode metadata tests** + +Create `FeaturedHeroMetadataTest.kt`: + +```kotlin +package org.prairieserver.prairie.android.ui.screens.home + +import org.prairieserver.prairie.model.catalog.OverlaySummary +import org.prairieserver.prairie.model.section.SectionItem +import kotlin.test.Test +import kotlin.test.assertEquals + +class FeaturedHeroMetadataTest { + @Test + fun movieUsesOrderedEditorialMetadataWithoutGenericOrTechnicalChips() { + val chips = featuredHeroMetadata( + SectionItem( + contentId = "movie-1", + type = "movie", + title = "Arrival", + year = 2016, + genres = listOf("Science Fiction"), + ratingImdb = 7.9, + contentRating = "PG-13", + durationSeconds = 6_960.0, + overlaySummary = OverlaySummary( + resolution = "2160p", + hdr = "Dolby Vision", + audio = "Atmos", + ), + ), + ) + + assertEquals( + listOf("2016", "1h 56m", "7.9", "Science Fiction", "PG-13"), + chips.map { it.label }, + ) + assertEquals( + listOf( + FeaturedHeroMetadataKind.Plain, + FeaturedHeroMetadataKind.Plain, + FeaturedHeroMetadataKind.Rating, + FeaturedHeroMetadataKind.Plain, + FeaturedHeroMetadataKind.Classification, + ), + chips.map { it.kind }, + ) + } + + @Test + fun episodeReliesOnExistingSeriesEyebrowAndTitleWithoutDuplicatingName() { + val chips = featuredHeroMetadata( + SectionItem( + contentId = "episode-1", + type = "episode", + title = "Long, Long Time", + seriesTitle = "The Last of Us", + seasonNumber = 1, + episodeNumber = 3, + ratingImdb = 8.6, + contentRating = "TV-MA", + durationSeconds = 4_560.0, + ), + ) + + assertEquals( + listOf("S1 E3", "1h 16m", "8.6", "TV-MA"), + chips.map { it.label }, + ) + } +} +``` + +- [ ] **Step 2: Write phone invalid-value tests** + +Append: + +```kotlin +@Test +fun invalidRatingsAndDurationsAreOmitted() { + listOf( + Double.NaN, + Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY, + 0.0, + -1.0, + ).forEachIndexed { index, invalid -> + val chips = featuredHeroMetadata( + SectionItem( + contentId = "invalid-$index", + type = "movie", + title = "Invalid", + ratingImdb = invalid, + durationSeconds = invalid, + ), + ) + + assertEquals(emptyList(), chips) + } +} +``` + +- [ ] **Step 3: Strengthen the TV invalid-value regression** + +Append to `TvFocusMarqueeModelTest`: + +```kotlin +@Test +fun invalidRatingsAndDurationsAreOmittedFromTvMetadata() { + listOf( + Double.NaN, + Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY, + 0.0, + -1.0, + ).forEachIndexed { index, invalid -> + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "invalid-$index", + type = "movie", + title = "Invalid", + ratingImdb = invalid, + durationSeconds = invalid, + ), + rowTitle = "Invalid", + ) + + assertEquals(emptyList(), content.metaParts) + } +} +``` + +- [ ] **Step 4: Run both focused model tests and verify RED** + +```bash +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests "org.prairieserver.prairie.android.ui.screens.home.FeaturedHeroMetadataTest" \ + --tests "org.prairieserver.prairie.tv.ui.components.TvFocusMarqueeModelTest" \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: phone compilation fails because the new metadata types/functions do +not exist; the TV invalid-value case also fails before the production guard is +added. If Gradle applies each `--tests` pattern to both module tasks and reports +that one module has no matching tests, run the two module/test pairs as separate +commands while retaining the same worker/no-daemon/rerun shape. + +- [ ] **Step 5: Implement the pure phone metadata mapper** + +Create `FeaturedHeroMetadata.kt`: + +```kotlin +package org.prairieserver.prairie.android.ui.screens.home + +import java.util.Locale +import kotlin.math.roundToInt +import org.prairieserver.prairie.model.section.SectionItem + +internal enum class FeaturedHeroMetadataKind { + Plain, + Rating, + Classification, +} + +internal data class FeaturedHeroMetadataChip( + val label: String, + val kind: FeaturedHeroMetadataKind = FeaturedHeroMetadataKind.Plain, +) + +internal fun featuredHeroMetadata(item: SectionItem): List { + val result = mutableListOf() + val isEpisode = item.type.equals("episode", ignoreCase = true) + + if (isEpisode) { + episodeToken(item.seasonNumber, item.episodeNumber)?.let { + result += FeaturedHeroMetadataChip(it) + } + } else if (item.year > 0) { + result += FeaturedHeroMetadataChip(item.year.toString()) + } + + formatFeaturedRuntime(item.durationSeconds)?.let { + result += FeaturedHeroMetadataChip(it) + } + item.ratingImdb + ?.takeIf { it.isFinite() && it > 0.0 } + ?.let { + result += FeaturedHeroMetadataChip( + label = String.format(Locale.US, "%.1f", it), + kind = FeaturedHeroMetadataKind.Rating, + ) + } + if (!isEpisode) { + item.genres.firstOrNull { it.isNotBlank() }?.let { + result += FeaturedHeroMetadataChip(it) + } + } + item.contentRating + ?.takeIf { it.isNotBlank() } + ?.uppercase(Locale.US) + ?.let { + result += FeaturedHeroMetadataChip( + label = it, + kind = FeaturedHeroMetadataKind.Classification, + ) + } + return result +} + +private fun episodeToken(season: Int?, episode: Int?): String? = when { + season != null && episode != null -> "S$season E$episode" + season != null -> "Season $season" + episode != null -> "Episode $episode" + else -> null +} + +private fun formatFeaturedRuntime(durationSeconds: Double?): String? { + val duration = durationSeconds?.takeIf { it.isFinite() && it > 0.0 } + ?: return null + val minutes = (duration / 60.0).roundToInt().takeIf { it > 0 } + ?: return null + if (minutes < 60) return "$minutes min" + val hours = minutes / 60 + val remainder = minutes % 60 + return if (remainder == 0) "${hours}h" else "${hours}h ${remainder}m" +} +``` + +- [ ] **Step 6: Render bounded phone chips without changing carousel actions** + +In `FeaturedCarousel.kt`: + +- import `ExperimentalLayoutApi` and `FlowRow` from + `androidx.compose.foundation.layout`; +- annotate `FeaturedCardContent` with `@OptIn(ExperimentalLayoutApi::class)`; +- use `remember(item) { featuredHeroMetadata(item) }` for the phone hero + metadata; +- replace the single `Row` with: + +```kotlin +FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + maxLines = 2, +) { + chips.forEach { chip -> MetadataChip(chip) } +} +``` + +Change `MetadataChip` to accept `FeaturedHeroMetadataChip` and render the star +only for `FeaturedHeroMetadataKind.Rating`: + +```kotlin +if (chip.kind == FeaturedHeroMetadataKind.Rating) { + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + tint = Color(0xFFFFCA28), + modifier = Modifier.size(12.dp), + ) +} +Text( + text = chip.label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = Color.White.copy(alpha = 0.94f), + maxLines = 1, +) +``` + +Delete `HeroChip`, `metadataChips`, and the duplicate item-based +`episodeToken`; keep `eyebrowFor`, paging, actions, backdrop, and carousel +geometry unchanged. + +- [ ] **Step 7: Reject invalid TV ratings and durations** + +In `TvMarqueeContent.from`, guard the rating at both movie and episode call +sites: + +```kotlin +item.ratingImdb + ?.takeIf { it.isFinite() && it > 0.0 } + ?.let { meta.add(formatRating(it)) } +``` + +Start `lengthText` with: + +```kotlin +val duration = durationSeconds?.takeIf { it.isFinite() && it > 0.0 } + ?: return null +val minutes = (duration / 60.0).roundToInt() +``` + +Use `duration` rather than the nullable input for the calculation. Do not alter +valid formatting or field order. + +- [ ] **Step 8: Run both focused model tests and verify GREEN** + +Run the module-specific commands described in Step 4. + +Expected: all phone metadata tests and all TV marquee-model tests pass. + +- [ ] **Step 9: Commit the phone-parity and invalid-value correction** + +```bash +git add \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedCarousel.kt \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadata.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadataTest.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt +git commit -m "fix(android): align editorial browse hero metadata" +``` + +--- + +### Task 4: Verify, review, package, and update PR #126 + +**Files:** +- Verify only: all branch changes against `origin/main` +- Output only, not committed: Android phone and TV universal minified release APKs +- Update remotely after green: existing PR #126 description/checklist + +**Interfaces:** +- Consumes: Tasks 1–3 commits. +- Produces: independently reviewed, fully verified PR #126 head and clearly named phone/TV tester APKs. + +- [ ] **Step 1: Run all focused regressions together** + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests "org.prairieserver.prairie.tv.ui.shell.TvTopMenuFocusRequestTest" \ + --tests "org.prairieserver.prairie.tv.ui.shell.TvShellFocusStateTest" \ + --tests "org.prairieserver.prairie.tv.ui.components.TvSkylineUpNavigationTest" \ + --tests "org.prairieserver.prairie.tv.ui.components.TvFocusMarqueeModelTest" \ + --rerun-tasks --max-workers=2 --no-daemon + +./gradlew :androidApp:testDebugUnitTest \ + --tests "org.prairieserver.prairie.android.ui.screens.home.FeaturedHeroMetadataTest" \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: all selected tests pass with zero failures. + +- [ ] **Step 2: Run supply-chain policy checks** + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +``` + +Expected: both scripts exit 0. + +- [ ] **Step 3: Run the complete fresh phone/TV test and release gate** + +```bash +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + :androidApp:assembleRelease \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: `BUILD SUCCESSFUL`; all phone and TV unit XML results have zero +failures and both universal minified release APKs are produced. + +- [ ] **Step 4: Perform the required device/emulator smoke** + +On a dedicated Android TV emulator or tester device, without touching an +unapproved physical device: + +1. Open Home, press Down into content, move to the first row, then press a + fresh Up; verify Home receives focus. +2. Repeat for one library section, For You, and Calendar; verify the active + pill receives focus each time. +3. Hold Up from a lower row; verify focus stops on the first content row. +4. Open Search and verify its route still owns Search focus. +5. Focus one movie and one episode; verify no resolution/HDR/audio badges are + shown and the approved editorial fields appear in order. + +Record the emulator/device identity and pass/fail result in the PR. If no +approved target is available, mark this smoke as pending instead of claiming +it passed. + +On a dedicated phone emulator, without touching an unapproved physical device: + +1. Open a Library Recommended featured carousel containing a movie and verify + year, runtime, IMDb, genre, and classification appear without a generic + `Movie` chip. +2. Open an episode carousel page and verify the series eyebrow/title remain, + with season/episode, runtime, IMDb, and classification chips and no generic + `Episode` chip. +3. Use a narrow phone viewport and verify chips wrap to no more than two lines + without covering Play or More Info. +4. Verify carousel paging, artwork, Play, and More Info behavior remain intact. + +Record the phone emulator identity and result, or mark this smoke pending when +no approved target is available. + +- [ ] **Step 5: Request independent focused review** + +Provide the reviewer: + +- the approved spec; +- `git diff origin/main...HEAD`; +- focused and full test results; +- the focus request timing/target contract; +- phone and TV movie/episode metadata ordering and invalid-value omission; +- the explicit scope boundary: Task 1 focus is TV-only, Task 2/3 hero metadata + is phone+TV, and earlier shared hydration/navigation-performance commits in + PR #126 are an accepted unchanged baseline rather than new Task 3 scope. + +Address only verified findings, rerun the affected focused test, and repeat +review until approved. + +- [ ] **Step 6: Verify diff hygiene and branch state** + +```bash +git diff --check origin/main...HEAD +git status --short --branch +git log --oneline origin/main..HEAD +``` + +Expected: no whitespace errors, no uncommitted files, and only the documented +navigation/hero commits plus their specs/plans. + +- [ ] **Step 7: Copy and verify both tester APKs** + +Select the universal APK from each release output, verify each with +`apksigner verify --verbose`, inspect package/version/ABI metadata with +`apkanalyzer`, calculate `shasum -a 256`, and copy it without overwriting prior +artifacts: + +```bash +cp androidApp/build/outputs/apk/release/androidApp-universal-release.apk \ + "Prairie-Phone-Universal-0.3.11-FocusHeroFix-$(git rev-parse --short HEAD).apk" +cp androidTvApp/build/outputs/apk/release/androidTvApp-universal-release.apk \ + "Prairie-TV-Universal-0.3.11-TVFocusHeroFix-$(git rev-parse --short HEAD).apk" +``` + +If a generated filename differs, select its universal artifact explicitly; +never substitute an ABI-specific split. Report that +`-PallowDebugReleaseSigning=true` produces a debug-signed release build that +only upgrades installations signed by the same certificate. + +- [ ] **Step 8: Push and update PR #126** + +```bash +git push origin fix/tv-for-you-cold-navigation +gh pr view 126 --repo Prairie-Server/prairie-android \ + --json state,isDraft,baseRefName,headRefName,mergeable,statusCheckRollup +``` + +Update the PR description to include: + +- active-section focus restoration; +- editorial-only phone and TV movie/episode hero metadata; +- invalid rating/runtime omission; +- focused/full verification evidence; +- independent review verdict; +- phone and TV smoke results or their explicit pending status; +- both tester APK signing caveats. + +Do not merge PR #126. diff --git a/docs/superpowers/plans/2026-07-28-pr-126-coderabbit-remediation.md b/docs/superpowers/plans/2026-07-28-pr-126-coderabbit-remediation.md new file mode 100644 index 000000000..8ff9dc40e --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-pr-126-coderabbit-remediation.md @@ -0,0 +1,527 @@ +# PR #126 CodeRabbit Remediation Implementation Plan + +> **Status:** Completed 2026-07-28. All remediation tasks and their recorded verification/review steps were completed on `fix/tv-for-you-cold-navigation`. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkboxes for tracking. + +**Goal:** Resolve every substantiated CodeRabbit finding on PR #126 while preserving intentional shared-request and Watch Together delivery semantics. + +**Architecture:** Keep each correction at its current boundary: TV shell wiring owns destination/focus routing, the marquee state owns page-entry versus real-focus arbitration, phone/TV presentation helpers own metadata validation, and repositories own identity-safe cache writes. Add focused regressions before behavioral changes; use existing characterization tests for behavior-preserving refactors. + +**Tech Stack:** Kotlin 2.1, Jetpack Compose, Kotlin coroutines, Kotlin test/JUnit, Gradle, shell supply-chain policy scripts. + +## Global Constraints + +- Work only on `fix/tv-for-you-cold-navigation`; do not merge PR #126. +- Preserve shared in-flight recommendation requests; do not cancel repository work when one UI caller is superseded. +- Preserve Watch Together attach, cadence, and delivery semantics. +- Settled real TV card focus always wins over a page-entry marquee seed. +- IMDb ratings are valid only when finite and within `(0, 10]` on phone and TV. +- Do not install or deploy APKs. +- Address proven false positives with explicit invariants or documentation, not semantic changes. + +--- + +## File map + +- `androidTvApp/.../TvMainShell.kt`: root-destination selection and For You request reset. +- `androidTvApp/.../TvLibraryDetailScreen.kt`: Alphabet rail fallback forwarding. +- `androidTvApp/.../TvForYouEntryRequest.kt`: testable request transition. +- `androidTvApp/.../TvSkylineSectionFeed.kt`: initial marquee seed effect identity. +- `androidTvApp/.../TvFocusMarqueeModel.kt`: page-entry seed arbitration and TV metadata validation. +- `androidApp/.../FeaturedHeroMetadata.kt`: phone metadata validation. +- `shared/.../RoomDeliveryLatch.kt`: compiler-visible nullable-key invariant. +- Phone/TV room-sync controllers: non-null delivery-key binding at state-report call sites. +- `shared/.../SectionRepository.kt`: injectable home-request dispatcher. +- Phone library state logic and shared repositories: behavior-preserving helper extractions. +- Existing focused unit-test files plus small source-contract tests: regressions and wiring verification. +- Three review/plan/report documents: wording, stale references, and local-path hygiene. + +### Task 1: TV destination and focus wiring + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailScreen.kt:158-167` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt:548-601` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequest.kt:8-14` +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequestTest.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryReviewWiringSourceTest.kt` + +**Interfaces:** +- Consumes: `TvForYouEntryRequest.next(selection: SavedListSelection?)`. +- Produces: `TvForYouEntryRequest.nextForTopLevelForYou(): TvForYouEntryRequest`; Alphabet and Calendar both register their content-Up fallback with the shell. + +- [x] **Step 1: Write the For You RED regression** + +Add to `TvForYouEntryRequestTest`: + +```kotlin +@Test +fun topLevelForYouRequestClearsSavedListSelection() { + val request = TvForYouEntryRequest( + sequence = 9, + selection = SavedListSelection.Watchlist, + ).nextForTopLevelForYou() + + assertEquals(10, request.sequence) + assertNull(request.selection) +} +``` + +- [x] **Step 2: Write the Alphabet wiring RED regression** + +Create a source-contract test that loads +`TvLibraryDetailScreen.kt`, isolates the +`TvLibraryTab.Alphabet -> LibraryTab(...)` block, and asserts it contains: + +```kotlin +onContentUpFallbackChanged = onContentUpFallbackChanged +``` + +Also assert the existing `TvCalendarScreen(...)` call still contains +`onContentUpFallbackChanged = onContentUpFallback`. + +- [x] **Step 3: Run RED** + +Run: + +```bash +./gradlew --no-daemon :androidTvApp:testDebugUnitTest \ + --tests '*TvForYouEntryRequestTest' \ + --tests '*TvLibraryReviewWiringSourceTest' \ + --max-workers=2 +``` + +Expected: failure because `nextForTopLevelForYou` is absent and the Alphabet +branch does not forward the fallback. + +- [x] **Step 4: Implement the minimal GREEN changes** + +Add: + +```kotlin +fun nextForTopLevelForYou(): TvForYouEntryRequest = next(null) +``` + +At the start of `onSelectRoot`, update only the For You destination: + +```kotlin +if (dest == TvRootDestination.ForYou) { + forYouEntryRequest = forYouEntryRequest.nextForTopLevelForYou() +} +``` + +Forward `onContentUpFallbackChanged` in the Alphabet `LibraryTab` call exactly +as Browse already does. + +- [x] **Step 5: Run GREEN and focused neighboring tests** + +Run: + +```bash +./gradlew --no-daemon :androidTvApp:testDebugUnitTest \ + --tests '*TvForYouEntryRequestTest' \ + --tests '*TvLibraryReviewWiringSourceTest' \ + --tests '*TvCalendarFocusRoutingTest' \ + --tests '*TvLibraryFocusRestoreTest' \ + --max-workers=2 +``` + +Expected: all selected tests pass. + +- [x] **Step 6: Commit Task 1** + +```bash +git add androidTvApp/src/androidMain androidTvApp/src/androidUnitTest +git commit -m "fix(tv): close reviewed focus routing gaps" +``` + +### Task 2: Marquee identity and metadata bounds + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineSectionFeed.kt:106-123` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt:76-165,229-285` +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt` +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeEnrichmentTest.kt` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadata.kt:18-55` +- Modify: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadataTest.kt` + +**Interfaces:** +- Consumes: `TvFocusMarqueeState.seedInitialPreview`, `TvMarqueeContent.from`, and `featuredHeroMetadata`. +- Produces: identity-aware page-entry reseeding that stops after real focus; consistent `(0, 10]` rating tokens on both clients. + +- [x] **Step 1: Write page-entry identity RED tests** + +Add tests that: + +```kotlin +state.seedInitialPreview(item, "Row", rowIdentity = "row-old") +state.commit(state.candidate) +state.seedInitialPreview(item, "Row", rowIdentity = "row-new") +assertEquals("row-new#item-1", state.candidate?.id) +``` + +and: + +```kotlin +state.preview(focusedItem, "Focused", rowIdentity = "focused-row") +state.commit(state.candidate) +state.seedInitialPreview(seedItem, "Replacement", rowIdentity = "replacement-row") +assertEquals("focused-row#focused-item", state.content?.id) +assertEquals("focused-row#focused-item", state.candidate?.id) +``` + +- [x] **Step 2: Write phone/TV rating and mixed-field RED tests** + +For both clients assert: + +```kotlin +ratingImdb = 11.0 +``` + +produces no rating token. Add two independent cases: + +```kotlin +ratingImdb = Double.NaN +durationSeconds = 7_200.0 +``` + +retains `"2h"`, while: + +```kotlin +ratingImdb = 8.4 +durationSeconds = Double.NaN +``` + +retains `"8.4"`. + +- [x] **Step 3: Run RED** + +```bash +./gradlew --no-daemon \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*FeaturedHeroMetadataTest' \ + --tests '*TvFocusMarqueeModelTest' \ + --tests '*TvFocusMarqueeEnrichmentTest' \ + --max-workers=2 +``` + +Expected: the row-identity and upper-bound assertions fail. + +- [x] **Step 4: Implement minimal marquee arbitration** + +Keep `seedInitialPreview` as the boundary. Build `next` first, ignore seeds once +`focusedMarqueeId != null`, and otherwise replace a different page-entry +candidate/content identity: + +```kotlin +fun seedInitialPreview(item: SectionItem, rowTitle: String, rowIdentity: String = rowTitle) { + if (focusedMarqueeId != null) return + val next = TvMarqueeContent.from(item, rowTitle, rowIdentity) + if (candidate?.id == next.id || content?.id == next.id) return + candidate = next +} +``` + +Include `initialMarqueeSeed?.rowIdentity` in the `LaunchedEffect` key and call +`seedInitialPreview` without an outer `marquee.content == null` gate. + +- [x] **Step 5: Implement bounded shared rating helpers** + +In each client boundary use a small private helper equivalent to: + +```kotlin +private fun validImdbRating(rating: Double?): Double? = + rating?.takeIf { it.isFinite() && it > 0.0 && it <= 10.0 } +``` + +Route both TV episode/non-episode branches through one TV `ratingToken` helper +to remove the duplicated filter. + +- [x] **Step 6: Run GREEN** + +Repeat the Task 2 focused command. Expected: all selected tests pass. + +- [x] **Step 7: Commit Task 2** + +```bash +git add androidApp/src/androidMain androidApp/src/androidUnitTest \ + androidTvApp/src/androidMain androidTvApp/src/androidUnitTest +git commit -m "fix(android): bound hero metadata and marquee identity" +``` + +### Task 3: Compiler-visible Watch Together delivery invariants + +**Files:** +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatch.kt:58-64` +- Modify: `shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatchTest.kt` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomSyncController.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomSyncController.kt:204-234` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomDeliveryKeySourceTest.kt` +- Create: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomDeliveryKeySourceTest.kt` + +**Interfaces:** +- Consumes: `RoomDeliveryLatch.isServerAttached`. +- Produces: identical delivery decisions with no nullable-key dereference or force-unwrapped reporting key. + +- [x] **Step 1: Strengthen nullable-key characterization** + +Add latch cases asserting `false` for: + +```kotlin +isServerAttached(key = null, echo = matchingEcho) +isServerAttached(key = validKey, echo = null) +isServerAttached(key = validKey, echo = wrongEpochEcho) +``` + +- [x] **Step 2: Write source-contract RED tests** + +Assert each room-sync controller reporting block does not contain +`deliveryKey!!` and does contain an explicit `deliveryKey != null` guard before +`stateReport`. + +- [x] **Step 3: Run RED** + +```bash +./gradlew --no-daemon \ + :shared:test \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*RoomDeliveryLatchTest' \ + --tests '*RoomDeliveryKeySourceTest' \ + --tests '*TvRoomDeliveryKeySourceTest' \ + --max-workers=2 +``` + +Historical pre-fix result: latch behavior remained green while both +source-contract tests failed because their reporting blocks used +`deliveryKey!!`. The current tree binds a non-null key before state reporting. + +- [x] **Step 4: Make nullability explicit without semantic changes** + +Use: + +```kotlin +key != null && + isAttached(key) && + echo != null && + echo.connectionGeneration == key.connectionGeneration +``` + +in the latch. In both controllers require a non-null local key before +`isServerAttached` and pass `key.playbackSessionId` to `stateReport`. + +- [x] **Step 5: Run GREEN and neighboring room-sync tests** + +Run the Task 3 command plus `*RoomSyncStateReportGateTest`. Expected: all pass. + +- [x] **Step 6: Commit Task 3** + +```bash +git add shared/src/commonMain shared/src/commonTest \ + androidApp/src/androidMain androidApp/src/androidUnitTest \ + androidTvApp/src/androidMain androidTvApp/src/androidUnitTest +git commit -m "refactor(watch-together): make delivery keys explicit" +``` + +### Task 4: Behavior-preserving review cleanups + +**Files:** +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SectionRepository.kt:15-34` +- Modify: `shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/SectionRepositoryCacheTest.kt:80-221` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt:688-708` +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/CatalogRepository.kt:47-177` +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PersonalDataRepository.kt:33-45` +- Verify: `shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/CatalogRepositoryDetailCacheTest.kt` +- Verify: `shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/PersonalDataRepositoryCacheTest.kt` + +**Interfaces:** +- Consumes: `CoroutineDispatcher`, identity transition generations, and `CatalogCacheWriteLease`. +- Produces: injectable home-request dispatcher and private helpers that preserve existing generation and cache-write decisions. + +- [x] **Step 1: Establish the characterization-test baseline** + +```bash +./gradlew --no-daemon :shared:test \ + --tests '*SectionRepositoryCacheTest' \ + --tests '*CatalogRepositoryDetailCacheTest' \ + --tests '*PersonalDataRepositoryCacheTest' \ + --max-workers=2 +``` + +Expected: all selected tests pass before refactoring. + +- [x] **Step 2: Inject the home request dispatcher** + +Add a constructor parameter with the existing runtime default: + +```kotlin +private val homeRequestDispatcher: CoroutineDispatcher = Dispatchers.Default +``` + +and use it in `homeRequestScope`. Update gated concurrency tests to pass a +`StandardTestDispatcher(testScheduler)` and extract one local helper that builds +the gated `MockEngine`/repository without changing assertions. + +- [x] **Step 3: Extract the phone library identity comparison** + +Add: + +```kotlin +private fun CatalogRequestIdentity.matches(state: LibrariesUiState): Boolean = + state.selectedLibraryId == libraryId && + state.browseSort == browseSort && + state.selectedNamePrefix == selectedNamePrefix && + state.filterState == filterState +``` + +Keep request/query generation comparisons in their respective methods. +Do not retain or cancel a recommendation `Job`; shared repository work must +remain independent of one superseded UI caller. + +- [x] **Step 4: Extract repository-local guarded-write helpers** + +In both repositories add the private pattern: + +```kotlin +private suspend fun writeIfIdentityUnchanged( + requestGeneration: Long, + write: suspend (CatalogCacheWriteLease) -> Unit, +) { + if (requestGeneration == identityTransitions.generation.value) { + write(CatalogCacheWriteLease(requestGeneration)) + } +} +``` + +Route existing cache write sites through it without moving network calls or +changing success/fallback behavior. + +- [x] **Step 5: Re-run characterization tests** + +Repeat the Task 4 baseline command. Expected: all selected tests pass with the +same assertions. + +- [x] **Step 6: Commit Task 4** + +```bash +git add shared/src/commonMain shared/src/commonTest androidApp/src/androidMain +git commit -m "refactor(android): clarify reviewed request guards" +``` + +### Task 5: Documentation and privacy corrections + +**Files:** +- Modify: `.superpowers/sdd/2026-07-28-android-tv-active-header-focus-editorial-hero/task-4-report.md` +- Modify: `docs/reviews/2026-07-27-android-tv-navigation-remediation-executive-summary.md:71` +- Modify: `docs/superpowers/plans/2026-07-28-android-tv-active-header-focus-editorial-hero.md:151,245` + +**Interfaces:** +- Consumes: current helper and focus-routing names. +- Produces: reproducible repository-relative evidence and implementation-accurate plan text. + +- [x] **Step 1: Replace local paths** + +Replace the worktree with `fix/tv-for-you-cold-navigation worktree` and replace +Desktop artifact paths with the artifact filenames while retaining hashes and +signing notes. + +- [x] **Step 2: Correct stale wording** + +Change `TV focused` to `TV-focused`, replace the stale +`FeaturedCarousel.metadataChips` reference with `featuredHeroMetadata`, and +update focus pseudocode to use `requestMenuFocusIfAvailable` plus the +`(focusRequest, focusRequestTarget)` handled identity. + +- [x] **Step 3: Verify documentation** + +```bash +rg -n --pcre2 '/(Users|home)/[^/[:space:]]+' \ + .superpowers/sdd/2026-07-28-android-tv-active-header-focus-editorial-hero/task-4-report.md +rg -n 'FeaturedCarousel\\.metadataChips|TV focused|requestMenuFocus\\(selectedMenuFocusTarget\\)' \ + docs/reviews/2026-07-27-android-tv-navigation-remediation-executive-summary.md \ + docs/superpowers/plans/2026-07-28-android-tv-active-header-focus-editorial-hero.md +git diff --check +``` + +Expected: both `rg` commands return no matches and `git diff --check` passes. + +- [x] **Step 4: Commit Task 5** + +```bash +git add .superpowers/sdd/2026-07-28-android-tv-active-header-focus-editorial-hero/task-4-report.md \ + docs/reviews/2026-07-27-android-tv-navigation-remediation-executive-summary.md \ + docs/superpowers/plans/2026-07-28-android-tv-active-header-focus-editorial-hero.md +git commit -m "docs: resolve PR 126 review findings" +``` + +### Task 6: Full verification, independent review, and PR update + +**Files:** +- Verify: all Task 1-5 files. +- Update remotely: PR #126 branch and description/check state only; do not merge. + +**Interfaces:** +- Consumes: all correction commits. +- Produces: reviewed branch with fresh focused, full, supply-chain, and release evidence. + +- [x] **Step 1: Run complete unit gates** + +```bash +./gradlew --no-daemon \ + :shared:test \ + :android-shared:testDebugUnitTest \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --max-workers=2 --rerun-tasks +``` + +Expected: exit 0 with no failed tests. + +- [x] **Step 2: Run supply-chain policy** + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +``` + +Expected: both exit 0. + +- [x] **Step 3: Run phone and TV release compilation** + +```bash +./gradlew --no-daemon \ + :androidApp:assembleRelease \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --max-workers=2 +``` + +Expected: `BUILD SUCCESSFUL`; these artifacts are verification-only and are not +installed or deployed. + +- [x] **Step 4: Perform diff and scope checks** + +```bash +git diff --check origin/main...HEAD +git status --short +git log --oneline origin/main..HEAD +``` + +Expected: no whitespace errors, no uncommitted changes, and only the documented +PR #126 plus review-remediation commits. + +- [x] **Step 5: Obtain independent focused review** + +Request review of `f822d22c..HEAD` against this plan and the design spec. Require +explicit assessment of the marquee real-focus invariant, For You reset, +Alphabet fallback, request-sharing non-cancellation, Watch Together delivery +semantics, and test adequacy. Fix any critical or important finding with a new +focused RED/GREEN cycle. + +- [x] **Step 6: Push and update PR #126** + +Push `fix/tv-for-you-cold-navigation`, update the PR verification summary with +fresh commands, and leave the PR open and unmerged. Record which CodeRabbit +suggestion was intentionally rejected because cancelling a UI caller must not +cancel shared repository work. diff --git a/docs/superpowers/plans/2026-07-28-subtitle-aspect-recenter.md b/docs/superpowers/plans/2026-07-28-subtitle-aspect-recenter.md new file mode 100644 index 000000000..3c261a1f0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-subtitle-aspect-recenter.md @@ -0,0 +1,550 @@ +# Subtitle Aspect-Mode Recentring Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep the Android phone and TV subtitle canvas aligned with the final visible video viewport when switching among Fit, Fill/Zoom, and Stretch. + +**Architecture:** `SubtitleManager` remains the only subtitle-geometry owner. A +pure mode-aware selector rejects stale fitted content-frame geometry while +preserving a matching post-layout content-frame rectangle so Zoom retains the +visible viewport's parent-local offset. The existing per-`PlayerView` +synchronizer performs one lifecycle-owned pre-draw reconciliation after each +explicit sync request and removes that observer on completion or disposal. + +**Tech Stack:** Kotlin 2.1, Android Views, Media3 `PlayerView`/`AspectRatioFrameLayout`, Robolectric/JUnit, Gradle 8.12. + +## Global Constraints + +- Apply the shared correction to Android phone and Android TV; phone is the confirmed reproduction. +- Fit aligns the subtitle canvas with the fitted video rectangle. +- Phone Fill/Media3 Zoom and phone Stretch/Media3 Fill align the canvas with the full visible player viewport. +- Preserve authored ASS/SSA and PGS positions relative to the canvas; do not rewrite individual cue coordinates. +- Preserve existing letterbox detection, title-safe insets, subtitle appearance, timing, track selection, playback state, networking, and persisted settings. +- Do not add polling, arbitrary delays, a second renderer, server changes, protocol changes, or transcoding changes. +- A delayed reconciliation must not mutate a detached or replaced `PlayerView`. +- Do not install on the Shield without a separate explicit request. + +--- + +### Task 1: Make subtitle canvas selection resize-mode aware + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt:445-492,662-673` +- Test: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt:143-251` + +**Interfaces:** +- Consumes: `SubtitleVideoRect`, Media3 resize-mode constants, `displayedSubtitleVideoRect(...)`, and the current content-frame rectangle. +- Produces: `internal fun selectSubtitleCanvasRect(resizeMode: Int, contentFrameRect: SubtitleVideoRect?, displayedVideoRect: SubtitleVideoRect): SubtitleVideoRect`. + +- [x] **Step 1: Add failing stale-frame regression tests** + +Add these tests to `SubtitleManagerAppearanceTest`: + +```kotlin +@Test +fun zoomIgnoresStaleFittedContentFrameAndUsesFullViewport() { + val staleFit = SubtitleVideoRect(left = 0, top = 236, width = 2404, height = 1352) + val fullViewport = SubtitleVideoRect(left = 0, top = 0, width = 2404, height = 1080) + + assertEquals( + fullViewport, + selectSubtitleCanvasRect( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + contentFrameRect = staleFit, + displayedVideoRect = fullViewport, + ), + ) +} + +@Test +fun stretchIgnoresStaleFittedContentFrameAndUsesFullViewport() { + val staleFit = SubtitleVideoRect(left = 240, top = 0, width = 1920, height = 1080) + val fullViewport = SubtitleVideoRect(left = 0, top = 0, width = 2400, height = 1080) + + assertEquals( + fullViewport, + selectSubtitleCanvasRect( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL, + contentFrameRect = staleFit, + displayedVideoRect = fullViewport, + ), + ) +} + +@Test +fun fitContinuesToUsePostLayoutContentFrame() { + val fittedFrame = SubtitleVideoRect(left = 0, top = 0, width = 1920, height = 1080) + val computedFallback = SubtitleVideoRect(left = 240, top = 0, width = 1920, height = 1080) + + assertEquals( + fittedFrame, + selectSubtitleCanvasRect( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT, + contentFrameRect = fittedFrame, + displayedVideoRect = computedFallback, + ), + ) +} + +@Test +fun repeatedModeSelectionDoesNotRetainPreviousCanvas() { + val fit = SubtitleVideoRect(left = 240, top = 0, width = 1920, height = 1080) + val full = SubtitleVideoRect(left = 0, top = 0, width = 2400, height = 1080) + + val fill = selectSubtitleCanvasRect( + AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + fit, + full, + ) + val stretch = selectSubtitleCanvasRect( + AspectRatioFrameLayout.RESIZE_MODE_FILL, + fit, + full, + ) + val restoredFit = selectSubtitleCanvasRect( + AspectRatioFrameLayout.RESIZE_MODE_FIT, + fit, + fit, + ) + + assertEquals(full, fill) + assertEquals(full, stretch) + assertEquals(fit, restoredFit) +} +``` + +- [x] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests org.prairieserver.prairie.common.player.SubtitleManagerAppearanceTest \ + --max-workers=2 --no-daemon +``` + +Expected: compilation fails because `selectSubtitleCanvasRect` does not exist. + +- [x] **Step 3: Implement the minimal mode-aware selector** + +Add beside `displayedSubtitleVideoRect`: + +```kotlin +internal fun selectSubtitleCanvasRect( + resizeMode: Int, + contentFrameRect: SubtitleVideoRect?, + displayedVideoRect: SubtitleVideoRect, +): SubtitleVideoRect = when (resizeMode) { + AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + AspectRatioFrameLayout.RESIZE_MODE_FILL, + -> contentFrameRect?.takeIf { + it.width == displayedVideoRect.width && + it.height == displayedVideoRect.height + } ?: displayedVideoRect + else -> contentFrameRect ?: displayedVideoRect +} +``` + +Change `SubtitleVideoRectSync.applyRect` to compute both inputs before applying +letterbox and title-safe insets: + +```kotlin +val resizeMode = playerView.resizeMode +val displayedVideoRect = displayedSubtitleVideoRect( + viewWidth = playerView.width, + viewHeight = playerView.height, + videoWidth = videoSize.width, + videoHeight = videoSize.height, + videoPixelWidthHeightRatio = videoSize.pixelWidthHeightRatio, + resizeMode = resizeMode, +) +val rect = selectSubtitleCanvasRect( + resizeMode = resizeMode, + contentFrameRect = playerView.contentFrameSubtitleRect(), + displayedVideoRect = displayedVideoRect, +).insetByLetterbox(letterbox).insetByTitleSafe(titleSafeFraction) +``` + +For Zoom and Fill, a content-frame rectangle is used only when its dimensions +match the visible viewport. This preserves the post-layout parent-local offset +of an oversized, negatively positioned Zoom frame. A stale fitted rectangle +does not match, so selection falls back to `displayedVideoRect`. + +- [x] **Step 4: Run the focused class and verify GREEN** + +Run the Step 2 command. + +Expected: `SubtitleManagerAppearanceTest` passes with zero failures. + +- [x] **Step 5: Commit the independently testable geometry correction** + +```bash +git add \ + android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt \ + android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt +git commit -m "fix(subtitles): recenter canvas for fill modes" +``` + +--- + +### Task 2: Reconcile once after layout and cancel stale callbacks + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt:270-282,563-704` +- Test: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt` + +**Interfaces:** +- Consumes: Task 1's `selectSubtitleCanvasRect(...)`. +- Produces: `SubtitleVideoRectSync.updateAndReconcileAfterLayout()`; at most one + pre-draw observer per `PlayerView`, removed after execution or during disposal. + +- [x] **Step 1: Add failing lifecycle regression tests** + +Add Robolectric tests that mount a real `PlayerView` in an `Activity`, invoke +`SubtitleManager.syncSubtitleVideoBounds`, drive layout and pre-draw, and assert +the actual `SubtitleView` layout parameters for Fit → Zoom, Fit → Fill, +repeated switching, and Zoom → Fit. Count completed reconciliations so deleting +the coalescing guard fails the suite, and use sentinel layout parameters to +prove a detached view cannot be mutated by a pending observer: + +```kotlin +@Test +fun repeatedExplicitSyncsRunOnePostLayoutReconciliation() { + val mounted = MountedSubtitleCanvas() + var reconciliations = 0 + mounted.manager.postLayoutReconciliationObserver = { reconciliations++ } + + repeat(5) { + mounted.manager.syncSubtitleVideoBounds(mounted.playerView) + } + mounted.dispatchPreDraw() + + assertEquals(1, reconciliations) +} + +@Test +fun detachCancelsPendingPostLayoutReconciliationWithoutMutatingLayout() { + val mounted = MountedSubtitleCanvas() + val sentinel = FrameLayout.LayoutParams(17, 19) + mounted.subtitleView.layoutParams = sentinel + + mounted.detach() + mounted.dispatchPreDraw() + + assertSame(sentinel, mounted.subtitleView.layoutParams) +} +``` + +The execution observer is instance-local, internal, and null by default. It +adds only a null check in production and does not retain a `PlayerView`. + +- [x] **Step 2: Run the focused class and verify RED** + +Run: + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests org.prairieserver.prairie.common.player.SubtitleManagerAppearanceTest \ + --max-workers=2 --no-daemon +``` + +Expected: mounted transition assertions fail before the content-frame offset +and lifecycle-owned post-layout reconciliation are implemented. + +- [x] **Step 3: Implement one lifecycle-owned post-layout callback** + +In `SubtitleVideoRectSync`, register one removable pre-draw observer: + +```kotlin +private var pendingPreDrawObserver: ViewTreeObserver? = null +private val postLayoutUpdate = ViewTreeObserver.OnPreDrawListener { + clearPendingPostLayoutUpdate() + if (!isDisposed) { + update() + onPostLayoutReconciled() + } + true +} + +fun updateAndReconcileAfterLayout() { + update() + val playerView = playerViewRef.get() ?: return + if (isDisposed) return + pendingPreDrawObserver?.let { observer -> + if (observer.isAlive) return + pendingPreDrawObserver = null + } + val observer = playerView.viewTreeObserver + if (!observer.isAlive) return + pendingPreDrawObserver = observer + observer.addOnPreDrawListener(postLayoutUpdate) +} +``` + +Change `SubtitleManager.syncSubtitleVideoBounds` to call: + +```kotlin +sync.updateAndReconcileAfterLayout() +``` + +In `dispose`, remove the observer before clearing listeners: + +```kotlin +clearPendingPostLayoutUpdate() +``` + +`clearPendingPostLayoutUpdate()` removes the listener from the exact +`ViewTreeObserver` used for registration and clears the reference. Ordinary +layout/video-size callbacks continue calling `update()` directly, keeping the +extra reconciliation bounded to explicit screen sync requests. + +- [x] **Step 4: Run focused tests and verify GREEN** + +Run the Step 2 command. + +Expected: all `SubtitleManagerAppearanceTest` tests pass. + +- [x] **Step 5: Run neighboring subtitle geometry tests** + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests org.prairieserver.prairie.common.player.SubtitleManagerAppearanceTest \ + --tests org.prairieserver.prairie.common.player.LetterboxInsetTest \ + --tests org.prairieserver.prairie.common.player.TitleSafeInsetTest \ + --max-workers=2 --no-daemon +``` + +Expected: zero failures. + +- [x] **Step 6: Commit the lifecycle correction** + +```bash +git add \ + android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt \ + android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt +git commit -m "fix(subtitles): reconcile canvas after aspect layout" +``` + +--- + +### Task 3: Lock phone/TV wiring and verify release behaviour + +**Files:** +- Create: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleAspectModeWiringSourceTest.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAspectModeWiringSourceTest.kt` +- Verify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt:1085-1123` +- Verify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt:1775-1793,3088-3113` + +**Interfaces:** +- Consumes: existing `SubtitleManager.syncSubtitleVideoBounds(PlayerView)`, phone resize-mode mapping, and TV `applyPlayerViewVideoFillMode`. +- Produces: platform source-contract tests ensuring each resize update is immediately followed by shared subtitle reconciliation. + +- [x] **Step 1: Add phone and TV source-contract tests** + +Phone: + +```kotlin +class SubtitleAspectModeWiringSourceTest { + private fun source(path: String): String { + val moduleRelative = File("src/androidMain/kotlin/$path") + val projectRelative = File("androidApp/src/androidMain/kotlin/$path") + return (moduleRelative.takeIf(File::exists) ?: projectRelative).readText() + } + + private fun playerViewUpdateBlock(source: String): String { + val factoryIndex = source.indexOf("PlayerView(ctx).apply {") + require(factoryIndex >= 0) { "PlayerView factory anchor is missing" } + val androidViewIndex = source.lastIndexOf("AndroidView(", factoryIndex) + require(androidViewIndex >= 0) { "Enclosing AndroidView is missing" } + val updateIndex = source.indexOf("update = { view ->", factoryIndex) + require(updateIndex > factoryIndex) { + "PlayerView update lambda is missing or misordered" + } + val endIndex = source.indexOf("modifier = Modifier", updateIndex) + require(endIndex > updateIndex) { + "PlayerView update lambda terminator is missing or misordered" + } + return source.substring(updateIndex, endIndex) + } + + @Test + fun playerViewReconcilesSubtitlesAfterResizeModeUpdate() { + val source = source( + "org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt" + ) + val update = playerViewUpdateBlock(source) + + assertTrue(update.contains("view.resizeMode = resizeMode")) + assertTrue(update.contains("subtitleManager.syncSubtitleVideoBounds(view)")) + assertTrue( + update.indexOf("view.resizeMode = resizeMode") < + update.indexOf("subtitleManager.syncSubtitleVideoBounds(view)") + ) + } +} +``` + +TV: + +```kotlin +class TvSubtitleAspectModeWiringSourceTest { + private fun source(path: String): String { + val moduleRelative = File("src/androidMain/kotlin/$path") + val projectRelative = File("androidTvApp/src/androidMain/kotlin/$path") + return (moduleRelative.takeIf(File::exists) ?: projectRelative).readText() + } + + private fun playerViewUpdateBlock(source: String): String { + val factoryIndex = source.indexOf(") as PlayerView).apply {") + require(factoryIndex >= 0) { "PlayerView factory anchor is missing" } + val androidViewIndex = source.lastIndexOf("AndroidView(", factoryIndex) + require(androidViewIndex >= 0) { "Enclosing AndroidView is missing" } + val updateIndex = source.indexOf("update = { view ->", factoryIndex) + require(updateIndex > factoryIndex) { + "PlayerView update lambda is missing or misordered" + } + val endIndex = source.indexOf( + "if (!isInPictureInPictureMode", + updateIndex, + ) + require(endIndex > updateIndex) { + "PlayerView update lambda terminator is missing or misordered" + } + return source.substring(updateIndex, endIndex) + } + + @Test + fun playerViewReconcilesSubtitlesAfterFillModeUpdate() { + val source = source( + "org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt" + ) + val update = playerViewUpdateBlock(source) + + val aspectCall = "applyPlayerViewVideoFillMode(view, state.videoFillMode)" + val subtitleCall = "subtitleManager.syncSubtitleVideoBounds(view)" + assertTrue(update.contains(aspectCall)) + assertTrue(update.contains(subtitleCall)) + assertTrue(update.indexOf(aspectCall) < update.indexOf(subtitleCall)) + } +} +``` + +Both files import `java.io.File`, `kotlin.test.Test`, and +`kotlin.test.assertTrue`. + +- [x] **Step 2: Prove the source tests detect reversed ordering** + +Temporarily reverse each extracted ordering assertion (`<` to `>`) and run: + +```bash +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*SubtitleAspectModeWiringSourceTest' \ + --max-workers=2 --no-daemon +``` + +Expected: both tests fail on their ordering assertion. Restore `<` before +continuing. + +- [x] **Step 3: Run the source tests GREEN** + +Run the Step 2 command after restoring the intended assertions. + +Expected: both tests pass. + +- [x] **Step 4: Run the complete relevant feature gate** + +```bash +./gradlew \ + :android-shared:testDebugUnitTest \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*SubtitleManagerAppearanceTest' \ + --tests '*LetterboxInsetTest' \ + --tests '*TitleSafeInsetTest' \ + --tests '*SubtitleAspectModeWiringSourceTest' \ + --max-workers=2 --no-daemon +``` + +Expected: zero failures. + +- [x] **Step 5: Run full debug unit tests** + +```bash +./gradlew testDebugUnitTest --max-workers=2 --no-daemon +``` + +Expected: build succeeds with zero test failures. + +- [x] **Step 6: Run supply-chain and release compilation gates** + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +./gradlew \ + :androidApp:assembleRelease \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --max-workers=2 --no-daemon +``` + +Expected: policy scripts exit zero and both minified release assemblies succeed. + +- [ ] **Step 7: Verify on the physical Pixel only — blocked: device disconnected** + +First confirm serial `58211FDCQ000CU`, compare the candidate and installed +package/version/signing certificate, and stop if the signer differs. Then use +only: + +```bash +adb -s 58211FDCQ000CU install -r \ + androidApp/build/outputs/apk/release/androidApp-universal-release.apk +adb -s 58211FDCQ000CU shell am start -W \ + -n org.prairieserver.prairie/org.prairieserver.prairie.android.MainActivity +``` + +With Bluetooth earbuds disconnected, play a title containing centred text +subtitles and switch Fit → Fill → Stretch → Fit. Capture screenshots after +layout settles and verify: + +- Fill and Stretch centre the subtitle canvas in the full visible viewport. +- Returning to Fit restores the fitted-video canvas. +- repeated switching does not retain an earlier offset; +- subtitle timing and vertical position remain stable; +- no immediate fatal exception, ANR, or player error appears in Pixel logcat. + +Do not issue any ADB command to the Shield or an emulator. + +- [x] **Step 8: Request independent focused review** + +Review only the branch diff against: + +- mode-aware stale-frame rejection; +- authored cue preservation; +- bounded callback ownership and detach cancellation; +- phone/TV wiring; +- absence of unrelated playback changes. + +Address every substantive finding test-first and rerun Tasks 1-3's focused +gates. + +- [x] **Step 9: Commit verification contracts** + +```bash +git add \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleAspectModeWiringSourceTest.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAspectModeWiringSourceTest.kt +git commit -m "test(subtitles): lock aspect recenter wiring" +``` + +- [x] **Step 10: Final diff and branch verification** + +```bash +git diff --check origin/main...HEAD +git status --short +git log --oneline origin/main..HEAD +``` + +Expected: no whitespace errors, clean worktree, and only the approved spec, +plan, shared geometry fix, lifecycle reconciliation, and platform tests. diff --git a/docs/superpowers/plans/2026-07-29-phone-director-credit-and-review-hardening.md b/docs/superpowers/plans/2026-07-29-phone-director-credit-and-review-hardening.md new file mode 100644 index 000000000..129a3a912 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-phone-director-credit-and-review-hardening.md @@ -0,0 +1,696 @@ +# Phone Director Credit and Review Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add PR #129's movie-only “Directed by …” hero credit to Android phone with one shared phone/TV formatting rule, add the missing PR #128/#129 regressions, and deterministically fix the unrelated hosted purger-test race. + +**Architecture:** A pure `movieDirectorCredit(ItemDetail): String?` presentation helper will live in `android-shared` and serve both Android clients. Phone and TV keep their platform-specific Compose rendering but use the shared string. Runtime work is characterization coverage only, while the purger correction is confined to explicit test-harness gates and does not change production semantics. + +**Tech Stack:** Kotlin 2.1, Jetpack Compose, Kotlin coroutines and `CompletableDeferred`, Kotlin test/JUnit, Gradle, repository shell supply-chain checks. + +## Global Constraints + +- Show the credit on movie detail pages only. +- Place it directly below the synopsis and optional description translation, and directly above the facts row. +- Match only crew jobs whose trimmed value equals `Director`, case-insensitively. +- Trim names, remove blanks, preserve first occurrence while de-duplicating, and show at most three names. +- Do not change server APIs, catalog models, navigation, cast/crew sections, or production purge behavior. +- Do not widen timeouts or add retries to conceal the hosted purger-test race. +- Preserve PR #128's existing positive-runtime preference and duration fallback. +- Update PR #129, but do not merge it. + +--- + +### Task 1: Shared Director-Credit Rule and TV Migration + +**Files:** +- Create: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/DirectorCredit.kt` +- Create: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/DirectorCreditTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDirectorCreditSourceTest.kt` + +**Interfaces:** +- Consumes: `org.prairieserver.prairie.model.catalog.ItemDetail` and its ordered `crew: List`. +- Produces: public pure function `fun movieDirectorCredit(detail: ItemDetail): String?`. +- Produces: TV hero wiring through `directorText = movieDirectorCredit(detail)`. + +- [ ] **Step 1: Write the failing shared formatting tests** + +Create `DirectorCreditTest.kt` with concrete movie, non-movie, exact-job, cleanup, de-duplication, and cap cases: + +```kotlin +package org.prairieserver.prairie.common.ui + +import org.prairieserver.prairie.model.catalog.CrewMember +import org.prairieserver.prairie.model.catalog.ItemDetail +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DirectorCreditTest { + @Test + fun movieCreditMatchesExactDirectorJobAndCleansNames() { + val detail = ItemDetail( + contentId = "movie-1", + type = "MoViE", + title = "Movie", + crew = listOf( + CrewMember(name = " Alice ", job = " director "), + CrewMember(name = "Camera", job = "Director of Photography"), + CrewMember(name = "", job = "Director"), + CrewMember(name = "Alice", job = "DIRECTOR"), + CrewMember(name = "Bob", job = "Director"), + ), + ) + + assertEquals("Directed by Alice, Bob", movieDirectorCredit(detail)) + } + + @Test + fun movieCreditKeepsServerOrderAndCapsAtThreeNames() { + val detail = ItemDetail( + contentId = "movie-2", + type = "movie", + title = "Movie", + crew = listOf("One", "Two", "Three", "Four").map { + CrewMember(name = it, job = "Director") + }, + ) + + assertEquals("Directed by One, Two, Three", movieDirectorCredit(detail)) + } + + @Test + fun movieCreditIsAbsentForNonMoviesOrMissingDirectors() { + assertNull( + movieDirectorCredit( + ItemDetail( + contentId = "episode-1", + type = "episode", + title = "Episode", + crew = listOf(CrewMember(name = "Alice", job = "Director")), + ), + ), + ) + assertNull( + movieDirectorCredit( + ItemDetail( + contentId = "movie-3", + type = "movie", + title = "Movie", + crew = listOf(CrewMember(name = "Camera", job = "Cinematographer")), + ), + ), + ) + } +} +``` + +- [ ] **Step 2: Run the shared test and verify RED** + +Run: + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.ui.DirectorCreditTest' \ + --no-daemon +``` + +Expected: compilation fails because `movieDirectorCredit` does not exist. + +- [ ] **Step 3: Implement the minimal shared rule** + +Create `DirectorCredit.kt`: + +```kotlin +package org.prairieserver.prairie.common.ui + +import org.prairieserver.prairie.model.catalog.ItemDetail + +fun movieDirectorCredit(detail: ItemDetail): String? { + if (!detail.type.equals("movie", ignoreCase = true)) return null + val names = detail.crew + .asSequence() + .filter { it.job?.trim().equals("Director", ignoreCase = true) } + .map { it.name.trim() } + .filter { it.isNotEmpty() } + .distinct() + .take(3) + .toList() + return names.takeIf { it.isNotEmpty() } + ?.joinToString(prefix = "Directed by ", separator = ", ") +} +``` + +- [ ] **Step 4: Write the failing TV wiring/placement test** + +Create `TvDirectorCreditSourceTest.kt`: + +```kotlin +package org.prairieserver.prairie.tv.ui.screens.detail + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class TvDirectorCreditSourceTest { + private val hero = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt", + ).readText() + private val screen = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt", + ).readText() + + @Test + fun tvMovieHeroUsesSharedDirectorCredit() { + assertTrue(screen.contains("directorText = movieDirectorCredit(detail)")) + } + + @Test + fun tvCreditStaysBetweenTranslationAndFacts() { + val translation = hero.indexOf("translation?.invoke()") + val director = hero.indexOf("directorText?.takeIf") + val facts = hero.indexOf("if (factsLine.isNotEmpty())", startIndex = director) + assertTrue(translation >= 0 && translation < director && director < facts) + } +} +``` + +- [ ] **Step 5: Run the TV source test and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.detail.TvDirectorCreditSourceTest' \ + --no-daemon +``` + +Expected: `tvMovieHeroUsesSharedDirectorCredit` fails because PR #129 still calls `TvDetailMetadata.directorText`. + +- [ ] **Step 6: Migrate TV to the shared helper** + +Delete `TvDetailMetadata.directorText`. Import `org.prairieserver.prairie.common.ui.movieDirectorCredit` in `TvItemDetailScreen.kt` and replace: + +```kotlin +directorText = TvDetailMetadata.directorText(detail), +``` + +with: + +```kotlin +directorText = movieDirectorCredit(detail), +``` + +- [ ] **Step 7: Run shared and TV tests and verify GREEN** + +Run: + +```bash +./gradlew \ + :android-shared:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*DirectorCreditTest' \ + --no-daemon +``` + +Expected: all director formatting and TV wiring tests pass. + +- [ ] **Step 8: Commit Task 1** + +```bash +git add \ + android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/DirectorCredit.kt \ + android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/DirectorCreditTest.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDirectorCreditSourceTest.kt +git commit -m "refactor(detail): share movie director credit" +``` + +--- + +### Task 2: Phone Movie-Hero Director Credit + +**Files:** +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt` +- Create: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/PhoneDirectorCreditSourceTest.kt` + +**Interfaces:** +- Consumes: Task 1's `fun movieDirectorCredit(detail: ItemDetail): String?`. +- Produces: optional `directorText: String? = null` parameter on `DetailHero`. +- Produces: movie-only phone wiring `directorText = movieDirectorCredit(detail)`. + +- [ ] **Step 1: Write the failing phone wiring/placement tests** + +Create `PhoneDirectorCreditSourceTest.kt`: + +```kotlin +package org.prairieserver.prairie.android.ui.screens.detail + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class PhoneDirectorCreditSourceTest { + private val hero = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt", + ).readText() + private val movie = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt", + ).readText() + + @Test + fun phoneMovieHeroUsesSharedDirectorCredit() { + assertTrue(hero.contains("directorText: String? = null")) + assertTrue(movie.contains("directorText = movieDirectorCredit(detail)")) + } + + @Test + fun phoneCreditStaysBetweenTranslationAndFacts() { + val translation = hero.indexOf("translation?.invoke()") + val director = hero.indexOf("directorText?.takeIf") + val facts = hero.indexOf("if (factsLine.isNotEmpty())", startIndex = director) + assertTrue(translation >= 0 && translation < director && director < facts) + } +} +``` + +- [ ] **Step 2: Run the phone test and verify RED** + +Run: + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.android.ui.screens.detail.PhoneDirectorCreditSourceTest' \ + --no-daemon +``` + +Expected: both tests fail because phone has no director hero parameter, rendering, or shared-helper call. + +- [ ] **Step 3: Add the minimal phone hero rendering** + +In `DetailSharedComponents.kt`, add this parameter immediately before `translation`: + +```kotlin +directorText: String? = null, +``` + +Immediately after `translation?.invoke()` and before the facts condition, render: + +```kotlin +directorText?.takeIf { it.isNotBlank() }?.let { line -> + Text( + text = line, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = DetailTertiaryText, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) +} +``` + +In `MovieDetailContent.kt`, import: + +```kotlin +import org.prairieserver.prairie.common.ui.movieDirectorCredit +``` + +and pass: + +```kotlin +directorText = movieDirectorCredit(detail), +``` + +to `DetailHero`. Do not change the `SeriesDetailContent` call; the optional default keeps all non-movie paths unchanged. + +- [ ] **Step 4: Run the phone and shared director tests and verify GREEN** + +Run: + +```bash +./gradlew \ + :android-shared:testDebugUnitTest \ + :androidApp:testDebugUnitTest \ + --tests '*DirectorCreditTest' \ + --no-daemon +``` + +Expected: shared formatting and phone wiring/placement tests pass. + +- [ ] **Step 5: Commit Task 2** + +```bash +git add \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/PhoneDirectorCreditSourceTest.kt +git commit -m "feat(phone): show movie director credit" +``` + +--- + +### Task 3: PR #128 Runtime Preference and Fallback Coverage + +**Files:** +- Modify: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadataTest.kt` +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt` + +**Interfaces:** +- Consumes: existing `featuredHeroMetadata(SectionItem): List`. +- Consumes: existing `TvMarqueeContent.from(SectionItem, String): TvMarqueeContent`. +- Produces: characterization coverage only; no production interface changes. + +- [ ] **Step 1: Add explicit phone preference and fallback tests** + +Append to `FeaturedHeroMetadataTest`: + +```kotlin +@Test +fun catalogRuntimeWinsOverPlaybackDurationOnPhone() { + val chips = featuredHeroMetadata( + SectionItem( + contentId = "movie-runtime", + type = "movie", + title = "Movie", + runtime = 125, + durationSeconds = 60.0, + ), + ) + + assertEquals(listOf("2h 5m"), chips.map { it.label }) +} + +@Test +fun invalidCatalogRuntimeFallsBackToPlaybackDurationOnPhone() { + val chips = featuredHeroMetadata( + SectionItem( + contentId = "movie-runtime-fallback", + type = "movie", + title = "Movie", + runtime = 0, + durationSeconds = 6_960.0, + ), + ) + + assertEquals(listOf("1h 56m"), chips.map { it.label }) +} +``` + +- [ ] **Step 2: Add explicit TV preference and fallback tests** + +Append to `TvFocusMarqueeModelTest`: + +```kotlin +@Test +fun catalogRuntimeWinsOverPlaybackDurationOnTv() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "movie-runtime", + type = "movie", + title = "Movie", + runtime = 125, + durationSeconds = 60.0, + ), + rowTitle = "Row", + ) + + assertEquals(listOf("2h 5m"), content.metaParts) +} + +@Test +fun invalidCatalogRuntimeFallsBackToPlaybackDurationOnTv() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "movie-runtime-fallback", + type = "movie", + title = "Movie", + runtime = 0, + durationSeconds = 6_960.0, + ), + rowTitle = "Row", + ) + + assertEquals(listOf("1h 56m"), content.metaParts) +} +``` + +- [ ] **Step 3: Prove the characterization tests detect regression** + +Temporarily replace the catalog-runtime branch in both production metadata builders with duration-only selection, without staging the mutation. Run: + +```bash +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*FeaturedHeroMetadataTest' \ + --tests '*TvFocusMarqueeModelTest' \ + --no-daemon +``` + +Expected: both `catalogRuntimeWins...` tests fail with `1m` instead of `2h 5m`. Restore the two production files exactly with: + +```bash +git restore \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadata.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt +``` + +- [ ] **Step 4: Run the runtime tests against real production code** + +Run: + +```bash +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*FeaturedHeroMetadataTest' \ + --tests '*TvFocusMarqueeModelTest' \ + --no-daemon +``` + +Expected: all phone and TV hero-runtime tests pass. + +- [ ] **Step 5: Commit Task 3** + +```bash +git add \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadataTest.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt +git commit -m "test(home): cover hero runtime preference" +``` + +--- + +### Task 4: Deterministic Purger Second-Pass Test Harness + +**Files:** +- Modify: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OrphanedServerDataPurgerTest.kt` + +**Interfaces:** +- Consumes: existing `OrphanedServerDataPurger.start(): Job`. +- Produces: explicit test-only `secondPurgeStarted` and `allowSecondPurge` gates. +- Does not modify `OrphanedServerDataPurger` or any production source. + +- [ ] **Step 1: Preserve the concrete RED evidence** + +Record the hosted assertion already observed: + +```text +OrphanedServerDataPurgerTest.removal during startup scan triggers a second purge +removed server was unexpectedly part of the startup orphan snapshot +OrphanedServerDataPurgerTest.kt:410 +``` + +Run the exact test once before editing: + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.downloads.OrphanedServerDataPurgerTest.removal during startup scan triggers a second purge' \ + --max-workers=2 --rerun-tasks --no-daemon +``` + +Expected: it may pass locally because the defect is an ordering race; the hosted failure is the RED evidence. Do not broaden production scope to force reproduction. + +- [ ] **Step 2: Add deterministic second-pass deletion gates** + +In the test, create: + +```kotlin +val secondPurgeStarted = CompletableDeferred() +val allowSecondPurge = CompletableDeferred() +``` + +Change the `purgeRows` branch for `serverId` so the gate occurs before deletion: + +```kotlin +purgeRows = { orphanId -> + if (orphanId == "preexisting-orphan") { + initialPurgeStarted.complete(Unit) + finishInitialPurge.await() + } + if (orphanId == serverId) { + secondPurgeStarted.complete(Unit) + allowSecondPurge.await() + } + db.serverPurgeDao().deleteAllRowsForServer(orphanId) + if (orphanId == "preexisting-orphan") { + initialRowsPurged.complete(Unit) + } + if (orphanId == serverId) { + rowsPurged.complete(Unit) + } +}, +``` + +After `initialRowsPurged.await()`, wait for the second pass before asserting: + +```kotlin +secondPurgeStarted.await() +assertTrue(observer.isActive, "purge observer stopped: $observerFailure") +assertNull(db.downloadDao().get("preexisting-orphan", "p1", 11)) +assertTrue( + db.downloadDao().get(serverId, "p1", 10) != null, + "second-pass deletion must remain gated until the snapshot assertion completes", +) + +allowSecondPurge.complete(Unit) +rowsPurged.await() +assertNull(db.downloadDao().get(serverId, "p1", 10)) +observer.cancel() +observer.join() +``` + +Remove the old ungated assertion/wait sequence. Do not add sleeps, retries, or timeout changes. + +- [ ] **Step 3: Run repeated exact-test verification** + +Run the exact command five times: + +```bash +for run in 1 2 3 4 5; do + ./gradlew :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.downloads.OrphanedServerDataPurgerTest.removal during startup scan triggers a second purge' \ + --max-workers=2 --rerun-tasks --no-daemon || exit 1 +done +``` + +Expected: 5/5 passes with the observer finishing cleanly. + +- [ ] **Step 4: Run the complete purger test class** + +Run: + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.downloads.OrphanedServerDataPurgerTest' \ + --max-workers=2 --rerun-tasks --no-daemon +``` + +Expected: the whole class passes. + +- [ ] **Step 5: Commit Task 4** + +```bash +git add android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OrphanedServerDataPurgerTest.kt +git commit -m "test(downloads): gate purger second-pass assertion" +``` + +--- + +### Task 5: Full Verification, Review, and PR #129 Update + +**Files:** +- Review: all files changed from `origin/main...HEAD` +- Update remotely: PR #129 branch `RXWatcher:feat/tv-detail-director-credit` + +**Interfaces:** +- Consumes: Tasks 1–4. +- Produces: a clean reviewed PR #129 head with fresh local and hosted evidence. + +- [ ] **Step 1: Run all focused regressions together** + +```bash +./gradlew \ + :android-shared:testDebugUnitTest \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*DirectorCreditTest' \ + --tests '*FeaturedHeroMetadataTest' \ + --tests '*TvFocusMarqueeModelTest' \ + --tests '*OrphanedServerDataPurgerTest' \ + --max-workers=2 --rerun-tasks --no-daemon +``` + +Expected: all selected tests pass. + +- [ ] **Step 2: Run supply-chain policy** + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +``` + +Expected: both scripts exit zero. + +- [ ] **Step 3: Run complete unit and compile gates** + +```bash +./gradlew \ + :android-shared:testDebugUnitTest \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + :androidApp:assembleDebug \ + :androidTvApp:assembleDebug \ + --max-workers=2 --no-daemon +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Audit scope and cleanliness** + +```bash +git diff --check origin/main...HEAD +git diff --stat origin/main...HEAD +git status --short +git diff --name-only origin/main...HEAD | \ + grep -E '(^server/|proxy|nginx|CatalogModels.kt)' && exit 1 || true +``` + +Expected: no whitespace errors, no unstaged changes, and no server/proxy/catalog-model files. + +- [ ] **Step 5: Request independent review** + +Provide the reviewer: + +- the approved spec; +- this plan; +- `git diff origin/main...HEAD`; +- focused and full verification results; and +- explicit review questions about exact Director matching, phone/TV placement parity, test-only purger ownership, and accidental production behavior changes. + +Resolve every substantive finding with a bounded RED/GREEN fix and rerun the affected focused test. Do not defer confirmed defects. + +- [ ] **Step 6: Push the reviewed branch to PR #129** + +```bash +git push git@github.com:RXWatcher/prairie-android.git \ + HEAD:feat/tv-detail-director-credit +``` + +Expected: the remote head advances without force-push and PR #129 retains its commit ancestry. + +- [ ] **Step 7: Verify PR state and hosted checks** + +```bash +gh pr view 129 --repo Prairie-Server/prairie-android \ + --json state,isDraft,baseRefName,headRefName,headRefOid,mergeable,reviewDecision,url +gh pr checks 129 --repo Prairie-Server/prairie-android --watch +``` + +Expected: PR #129 remains open against `main`; hosted checks finish green. Report review requirements separately. Do not merge. diff --git a/docs/superpowers/plans/2026-07-29-remove-tv-detail-starring-overlay.md b/docs/superpowers/plans/2026-07-29-remove-tv-detail-starring-overlay.md new file mode 100644 index 000000000..51520e722 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-remove-tv-detail-starring-overlay.md @@ -0,0 +1,307 @@ +# Remove Android TV Detail Starring Overlay Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the duplicated floating `Starring …` credit from the Android TV detail hero while preserving the lower cast/crew section and the shared movie director credit. + +**Architecture:** Delete the starring presentation at its existing TV-only boundaries: metadata derivation, detail-screen wiring, and hero rendering. Add one focused source-contract regression that proves those boundaries stay absent without changing cast models, the cast rail, phone UI, or any server-facing behavior. + +**Tech Stack:** Kotlin 2.1, Jetpack Compose for TV, Kotlin Test/JUnit, Gradle, Android Debug Bridge for an emulator-only smoke check. + +## Global Constraints + +- Remove only the Android TV detail hero's floating upper-right `Starring …` overlay. +- Preserve `TvCastCrewSection` as the complete TV cast and crew presentation. +- Preserve the movie-only `Directed by …` credit on Android TV and phone. +- Do not add a replacement shadow, glyph halo, vignette, panel, or inline actor-credit row. +- Preserve existing title-detail content, actions, focus behavior, hero gradients, synopsis, translation, and fact tokens. +- Make no phone production UI, server, API, model, persistence, navigation, playback, protocol, or Apple-client changes. +- Do not refactor unrelated hero layout or metadata formatting. + +--- + +## File Map + +- Modify `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt`: remove the `starringText` API, upper-right overlay, and obsolete KDoc. +- Modify `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt`: stop deriving and passing the starring credit. +- Modify `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt`: remove the unused `starringText(ItemDetail): String?` formatter. +- Create `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvStarringOverlaySourceTest.kt`: guard the three production boundaries against reintroducing the duplicate overlay. +- Preserve `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvCastCrewSection.kt`: no edits; its presence on the detail page is checked during review and smoke validation. +- Preserve `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDirectorCreditSourceTest.kt`: no edits; its existing tests continue to protect the director-credit call and ordering. + +### Task 1: Remove the TV Hero Starring Presentation + +**Files:** +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvStarringOverlaySourceTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt:55-98,160-190` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt:433-452` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt:80-85` +- Verify unchanged: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvCastCrewSection.kt` +- Test unchanged: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDirectorCreditSourceTest.kt` + +**Interfaces:** +- Consumes: `TvDetailHero(...)`, `TvDetailMetadata`, and the existing `TvCastCrewSection(...)` call in `TvItemDetailScreen`. +- Produces: `TvDetailHero(...)` without a `starringText: String?` parameter; `TvDetailMetadata` without `starringText(ItemDetail): String?`. + +- [ ] **Step 1: Write the failing source-contract regression** + +Create `TvStarringOverlaySourceTest.kt`: + +```kotlin +package org.prairieserver.prairie.tv.ui.screens.detail + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvStarringOverlaySourceTest { + private val hero = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt", + ).readText() + private val screen = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt", + ).readText() + private val metadata = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt", + ).readText() + + @Test + fun tvDetailDoesNotDeriveOrRenderDuplicatedStarringOverlay() { + assertFalse(hero.contains("starringText")) + assertFalse(screen.contains("TvDetailMetadata.starringText(detail)")) + assertFalse(metadata.contains("fun starringText(")) + } + + @Test + fun tvDetailStillRendersTheFullCastSection() { + assertTrue(screen.contains("TvCastCrewSection(")) + } +} +``` + +- [ ] **Step 2: Run the new test and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests org.prairieserver.prairie.tv.ui.screens.detail.TvStarringOverlaySourceTest \ + --no-daemon +``` + +Expected: `tvDetailDoesNotDeriveOrRenderDuplicatedStarringOverlay` fails because the current hero, screen, and metadata formatter still contain `starringText`. The cast-section assertion passes. + +- [ ] **Step 3: Remove the metadata formatter** + +Delete this function from `TvDetailMetadata.kt`: + +```kotlin +fun starringText(detail: ItemDetail): String? { + val names = detail.cast.take(3).map { it.name.trim() }.filter { it.isNotEmpty() } + if (names.isEmpty()) return null + return "Starring ${names.joinToString(", ")}" +} +``` + +Do not alter any other metadata token formatting. + +- [ ] **Step 4: Remove the detail-screen wiring** + +Delete only this argument from the `TvDetailHero` call in `TvItemDetailScreen.kt`: + +```kotlin +starringText = TvDetailMetadata.starringText(detail), +``` + +Leave the adjacent director call intact: + +```kotlin +directorText = movieDirectorCredit(detail), +``` + +Leave the existing `TvCastCrewSection(...)` call unchanged. + +- [ ] **Step 5: Remove the hero API and overlay** + +In `TvDetailHero.kt`: + +1. Remove `starringText: String?` from the `TvDetailHero` parameters. +2. Delete the complete `starringText?.takeIf { ... }` composable block. +3. Rewrite the class KDoc so the layout description ends with the bottom-anchored editorial and action column; remove the claim that a starring credit floats in the upper-right. +4. Keep `TextAlign`, `TextStyle`, `Shadow`, `Offset`, and `widthIn` imports because the remaining title, metadata, and editorial code still uses them. + +The resulting signature around the affected parameters must be: + +```kotlin +factsLine: List, +directorText: String?, +actions: @Composable () -> Unit, +``` + +- [ ] **Step 6: Run the focused tests and verify GREEN** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests org.prairieserver.prairie.tv.ui.screens.detail.TvStarringOverlaySourceTest \ + --tests org.prairieserver.prairie.tv.ui.screens.detail.TvDirectorCreditSourceTest \ + --tests org.prairieserver.prairie.tv.ui.screens.detail.TvDetailMetadataTest \ + --no-daemon +``` + +Expected: all selected tests pass. In particular, the new regression finds no starring derivation or rendering, the existing director-credit source tests keep passing, and unrelated metadata behavior is unchanged. + +- [ ] **Step 7: Inspect the production diff** + +Run: + +```bash +git diff --check +git diff -- \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvStarringOverlaySourceTest.kt +rg -n "starringText|Starring …|Starring " \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail +``` + +Expected: `git diff --check` succeeds; the diff contains only the scoped deletions plus the regression test; `rg` finds no production starring overlay or formatter. A match inside the new negative source-contract test is expected. + +- [ ] **Step 8: Commit the coherent behavior change** + +```bash +git add \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvStarringOverlaySourceTest.kt +git commit -m "fix(tv): remove duplicated hero starring overlay" +``` + +### Task 2: Verify the TV Detail Experience + +**Files:** +- Verify unchanged: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvCastCrewSection.kt` +- Verify unchanged: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt` +- Verify unchanged: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt` +- Verify: all files changed by Task 1 + +**Interfaces:** +- Consumes: the Task 1 `TvDetailHero(...)` signature without `starringText`. +- Produces: verification evidence that the TV app compiles, the cast rail remains, the movie director credit remains, and no phone production code changed. + +- [ ] **Step 1: Run the repository supply-chain checks** + +Run: + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +``` + +Expected: both scripts exit successfully without modifying dependency verification metadata. + +- [ ] **Step 2: Run the complete Android TV unit suite** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --max-workers=2 --no-daemon +``` + +Expected: `BUILD SUCCESSFUL` with no Android TV unit-test failures. + +- [ ] **Step 3: Compile both Android TV variants** + +Run: + +```bash +./gradlew \ + :androidTvApp:assembleDebug \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --max-workers=2 \ + --no-daemon +``` + +Expected: `BUILD SUCCESSFUL`. This is a compile/signing gate only; do not distribute the debug-signed release artifact. + +- [ ] **Step 4: Confirm platform and scope boundaries** + +Run: + +```bash +git diff origin/main...HEAD --name-only +git diff origin/main...HEAD -- androidApp shared android-shared +rg -n "TvCastCrewSection\\(|directorText = movieDirectorCredit\\(detail\\)" \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt +``` + +Expected: + +- the branch diff contains the approved spec, plan, three TV production files, and one TV test; +- the phone/shared diff is empty; +- both the TV cast section and director-credit wiring remain present. + +- [ ] **Step 5: Perform a dedicated TV-emulator smoke check** + +First prove that `emulator-5554` is the dedicated TV emulator before using it: + +```bash +adb -s emulator-5554 get-state +adb -s emulator-5554 shell getprop ro.boot.qemu.avd_name +adb -s emulator-5554 shell getprop ro.build.characteristics +``` + +Proceed only if the device state is `device`, the AVD name is `Silo_TV`, and characteristics include `tv`. Do not issue ADB commands to any physical serial. + +Install and launch the debug build: + +```bash +adb -s emulator-5554 install -r \ + androidTvApp/build/outputs/apk/debug/androidTvApp-universal-debug.apk +adb -s emulator-5554 shell monkey \ + -p org.prairieserver.prairie \ + -c android.intent.category.LEANBACK_LAUNCHER \ + 1 +``` + +Using the existing emulator profile, open a movie detail page and verify: + +1. no floating `Starring …` credit appears in the upper-right; +2. the backdrop remains unobstructed there; +3. `Directed by …` remains below synopsis/translation and above facts; +4. scrolling reaches the unchanged cast and crew section; +5. hero actions, directional focus, Back, and body scrolling behave normally. + +If `emulator-5554` is absent, offline, not `Silo_TV`, or requires destructive profile setup, do not substitute a physical device; record this single visual gate as pending. + +- [ ] **Step 6: Request focused code review** + +Provide the reviewer: + +- the approved design spec; +- this implementation plan; +- `git diff origin/main...HEAD`; +- focused/full test and build outputs; +- emulator evidence or the explicitly pending emulator gate. + +The review question is: does the branch remove every TV detail starring boundary while preserving cast/crew, director credit, phone scope, and existing focus/layout behavior? + +Address only findings that violate the approved scope or reveal a correctness regression. Re-run the smallest affected test after each correction, then repeat Steps 1–4 before completion. + +- [ ] **Step 7: Record final verification state** + +Run: + +```bash +git status --short --branch +git log --oneline --decorate origin/main..HEAD +git diff --check origin/main...HEAD +``` + +Expected: the worktree is clean; the branch contains the spec commit, plan commit, and implementation commit; the final diff has no whitespace errors. Do not merge or deploy as part of this plan. diff --git a/docs/superpowers/plans/2026-07-29-section-cache-profile-switch-test-race.md b/docs/superpowers/plans/2026-07-29-section-cache-profile-switch-test-race.md new file mode 100644 index 000000000..14415fcb3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-section-cache-profile-switch-test-race.md @@ -0,0 +1,95 @@ +# Section Cache Profile-Switch Test Race Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate the deterministic scheduling race in the shared section-cache test helper without changing production behavior. + +**Architecture:** Keep the existing gated mock engine and its two synchronization points. Assign the response fixture to the current request before notifying the waiting test coroutine, so later requests cannot change that request's fixture through the shared counter. + +**Tech Stack:** Kotlin, kotlinx.coroutines-test, Ktor MockEngine, Kotlin Test/JUnit, Gradle. + +## Global Constraints + +- Modify only the shared test helper. +- Make no production-code, timeout, worker-count, or application-binary changes. +- Preserve all existing profile-isolation assertions. +- Do not add sleeps, retries, or timeout widening. + +--- + +### Task 1: Order Fixture Capture Before Entry Notification + +**Files:** +- Modify: `shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/SectionRepositoryCacheTest.kt:56-61` + +**Interfaces:** +- Consumes: `onRequest: () -> Unit`, `body: () -> String`, `requestEntered: CompletableDeferred`. +- Produces: the same `gatedRepository(...)` helper signature and behavior with deterministic per-request fixture capture. + +- [x] **Step 1: Preserve RED evidence** + +Record the hosted failure from workflow `30453920065`: + +```text +expected:<[Old]> but was:<[New]> +at SectionRepositoryCacheTest.kt:218 +``` + +- [x] **Step 2: Apply the minimal ordering fix** + +Change the MockEngine body from: + +```kotlin +onRequest() +requestEntered.complete(Unit) +val responseBody = body() +releaseResponse.await() +``` + +to: + +```kotlin +onRequest() +val responseBody = body() +requestEntered.complete(Unit) +releaseResponse.await() +``` + +- [x] **Step 3: Verify the exact regression repeatedly** + +Run the exact test at least five times: + +```bash +for run in 1 2 3 4 5; do + ./gradlew :shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.repository.SectionRepositoryCacheTest.homeRequestStartedBeforeProfileSwitchDoesNotServeOldProfileSectionsToNewProfile' \ + --max-workers=2 --rerun-tasks --no-daemon +done +``` + +Expected: all five runs pass. + +- [x] **Step 4: Verify the containing class and shared suite** + +```bash +./gradlew :shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.repository.SectionRepositoryCacheTest' \ + --max-workers=2 --rerun-tasks --no-daemon + +./gradlew :shared:testDebugUnitTest \ + --max-workers=2 --rerun-tasks --no-daemon +``` + +Expected: both commands pass. + +- [x] **Step 5: Inspect and commit** + +```bash +git diff --check +git diff -- shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/SectionRepositoryCacheTest.kt +git add \ + docs/superpowers/specs/2026-07-29-section-cache-profile-switch-test-race-design.md \ + docs/superpowers/plans/2026-07-29-section-cache-profile-switch-test-race.md \ + shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/SectionRepositoryCacheTest.kt +git commit -m "test(shared): stabilize profile-switch request fixture" +``` diff --git a/docs/superpowers/plans/2026-07-29-specials-first-season-order.md b/docs/superpowers/plans/2026-07-29-specials-first-season-order.md new file mode 100644 index 000000000..da1325086 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-specials-first-season-order.md @@ -0,0 +1,255 @@ +# Android Specials-First Season Order Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Display Specials first in Android phone and TV season selectors while ordinary series openings continue to select the first regular season. + +**Architecture:** Put Specials detection, deterministic display sorting, and initial-season choice in shared catalog-model helpers. Both Android detail view models consume the same helpers, preventing phone/TV drift while leaving composables, routes, server responses, and playback sequencing unchanged. + +**Tech Stack:** Kotlin 2.1, Kotlin Multiplatform common code, Android ViewModel/coroutines, Kotlin Test/JUnit, Gradle. + +## Global Constraints + +- Visible order is `Specials, Season 1, Season 2, …`. +- Keep the visible label **Specials**; never relabel it “Season 0.” +- Treat a season as Specials when `isSpecials == true` or `seasonNumber == 0`. +- Honor a requested/deep-linked season, including Specials. +- Without a requested season, select the first regular season; select Specials only when no regular season exists. +- Do not change the Prairie server, web client, Apple clients, API schema, routes, or playback sequencing. + +--- + +### Task 1: Shared Ordering and Initial-Selection Contract + +**Files:** +- Create: `shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/SeasonDisplayOrderTest.kt` +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/CatalogModels.kt:354-381` + +**Interfaces:** +- Consumes: existing `Season(contentId, seasonNumber, isSpecials, title, …)`. +- Produces: + - `fun Season.isSpecialsForDisplay(): Boolean` + - `fun List.sortedForDisplay(): List` + - `fun List.initialSeasonForDisplay(preferredSeasonNumber: Int?): Season?` + +- [ ] **Step 1: Write failing shared ordering tests** + +Create `SeasonDisplayOrderTest.kt` with a local factory and these cases: + +```kotlin +package org.prairieserver.prairie.model.catalog + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SeasonDisplayOrderTest { + private fun season( + number: Int, + specials: Boolean = false, + id: String = "season-$number-$specials", + ) = Season( + contentId = id, + seasonNumber = number, + isSpecials = specials, + ) + + @Test + fun `specials sort before regular seasons`() { + val result = listOf(season(2), season(0), season(1)).sortedForDisplay() + assertEquals(listOf(0, 1, 2), result.map(Season::seasonNumber)) + } + + @Test + fun `specials flag is authoritative even for nonzero season number`() { + val result = listOf(season(1), season(99, specials = true), season(2)).sortedForDisplay() + assertEquals(listOf(99, 1, 2), result.map(Season::seasonNumber)) + } + + @Test + fun `ordinary opening selects first regular season`() { + val result = listOf(season(0), season(2), season(1)) + .initialSeasonForDisplay(preferredSeasonNumber = null) + assertEquals(1, result?.seasonNumber) + } + + @Test + fun `requested specials remains selected`() { + val result = listOf(season(2), season(0), season(1)) + .initialSeasonForDisplay(preferredSeasonNumber = 0) + assertEquals(0, result?.seasonNumber) + } + + @Test + fun `specials-only series selects specials`() { + val result = listOf(season(0)) + .initialSeasonForDisplay(preferredSeasonNumber = null) + assertEquals(0, result?.seasonNumber) + } +} +``` + +- [ ] **Step 2: Run the shared test and verify RED** + +Run: + +```bash +./gradlew :shared:testDebugUnitTest \ + --tests org.prairieserver.prairie.model.catalog.SeasonDisplayOrderTest \ + --no-daemon +``` + +Expected: compilation fails because `initialSeasonForDisplay` and +`isSpecialsForDisplay` do not exist, or the Specials-first assertion fails +against the current Specials-last comparator. + +- [ ] **Step 3: Implement the minimal shared helpers** + +Replace the current comparator and add: + +```kotlin +fun Season.isSpecialsForDisplay(): Boolean = + isSpecials || seasonNumber == 0 + +fun List.sortedForDisplay(): List = + sortedWith( + compareByDescending { it.isSpecialsForDisplay() } + .thenBy { it.seasonNumber } + .thenBy { it.title.orEmpty() } + .thenBy { it.contentId }, + ) + +fun List.initialSeasonForDisplay(preferredSeasonNumber: Int?): Season? { + val ordered = sortedForDisplay() + return preferredSeasonNumber + ?.let { preferred -> ordered.firstOrNull { it.seasonNumber == preferred } } + ?: ordered.firstOrNull { !it.isSpecialsForDisplay() } + ?: ordered.firstOrNull() +} +``` + +- [ ] **Step 4: Run the shared test and verify GREEN** + +Run the Step 2 command again. Expected: all five tests pass. + +- [ ] **Step 5: Commit the shared contract** + +```bash +git add \ + shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/CatalogModels.kt \ + shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/SeasonDisplayOrderTest.kt +git commit -m "fix(catalog): place specials first in season order" +``` + +--- + +### Task 2: Wire Phone and TV Initial Selection + +**Files:** +- Create: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeasonInitialSelectionWiringSourceTest.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSeasonInitialSelectionWiringSourceTest.kt` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt:463-480` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailViewModel.kt:652-673` + +**Interfaces:** +- Consumes: `List.sortedForDisplay()` and + `List.initialSeasonForDisplay(preferredSeasonNumber: Int?)` from + Task 1. +- Produces: identical phone/TV automatic selection behavior with existing + `selectedSeasonNumber` and `selectedSeason` state fields. + +- [ ] **Step 1: Write failing wiring tests** + +The phone test reads `ItemDetailViewModel.kt` and asserts: + +```kotlin +assertTrue( + source.contains( + "val selectedSeason = seasons.initialSeasonForDisplay(initialSeasonNumber)", + ), +) +``` + +The TV test reads `TvItemDetailViewModel.kt` and asserts: + +```kotlin +assertTrue( + source.contains( + "val selectedSeason = seasons.initialSeasonForDisplay(preferredSeasonNumber)", + ), +) +``` + +Each source-test file resolves its module-relative production file with +`File("src/androidMain/kotlin/…").readText()`. + +- [ ] **Step 2: Run both wiring tests and verify RED** + +Run: + +```bash +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*SeasonInitialSelectionWiringSourceTest' \ + --no-daemon +``` + +Expected: both tests fail because the view models still implement selection +inline. + +- [ ] **Step 3: Wire the shared selection helper** + +In the phone view model, import `initialSeasonForDisplay` and replace the +inline requested-or-first choice with: + +```kotlin +val seasons = result.data.seasons.sortedForDisplay() +val selectedSeason = seasons.initialSeasonForDisplay(initialSeasonNumber) +``` + +In the TV view model, import `initialSeasonForDisplay` and replace +`selectedSeason`/`firstRegular` with: + +```kotlin +val seasons = r.data.seasons.sortedForDisplay() +val selectedSeason = seasons.initialSeasonForDisplay(preferredSeasonNumber) +``` + +Use `selectedSeason` for `selectedSeason`, episode loading, and null fallback. +Do not change routing, state field types, or episode-loading behavior. + +- [ ] **Step 4: Run both wiring tests and verify GREEN** + +Run the Step 2 command again. Expected: both tests pass. + +- [ ] **Step 5: Run focused and compile verification** + +Run: + +```bash +./gradlew \ + :shared:testDebugUnitTest \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + :androidApp:assembleDebug \ + :androidTvApp:assembleDebug \ + --no-daemon +``` + +Expected: `BUILD SUCCESSFUL` with no failed tests. + +- [ ] **Step 6: Check the final diff and commit** + +```bash +git diff --check +git status --short +git add \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeasonInitialSelectionWiringSourceTest.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailViewModel.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSeasonInitialSelectionWiringSourceTest.kt +git commit -m "fix(android): keep regular season selected by default" +``` + +Confirm the final branch contains only the design, plan, shared ordering, +phone/TV wiring, and focused tests. diff --git a/docs/superpowers/plans/2026-07-30-playback-buffer-architecture.md b/docs/superpowers/plans/2026-07-30-playback-buffer-architecture.md new file mode 100644 index 000000000..497f3b140 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-playback-buffer-architecture.md @@ -0,0 +1,512 @@ +# Playback Buffer Architecture Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop the buffer from idling the socket long enough for an upstream proxy to drop the connection, while letting buffer depth grow as far as memory and throughput allow. + +**Architecture:** `maxBufferMs` becomes derived (`min + MAX_LOAD_IDLE_MS`) rather than hand-written, which makes the dropped-connection failure unrepresentable. Depth is governed by a memory budget and observed throughput, with an explicit floor and ceiling. The dead three-mode enum is deleted. + +**Tech Stack:** Kotlin 2.1, Java 21, AndroidX Media3 (ExoPlayer), kotlin.test/JUnit4 unit tests in `android-shared/src/androidUnitTest`. + +**Spec:** `docs/superpowers/specs/2026-07-30-playback-buffer-architecture-design.md` — read it first; it is the authority on behaviour and carries the reasoning behind every number. + +## Global Constraints + +- **The invariant is the point of this work:** `maxBufferMs == minBufferMs + MAX_LOAD_IDLE_MS`, always, for every reachable policy including after the memory budget reduces depth. `MAX_LOAD_IDLE_MS = 15_000` — a 30s wall-clock budget scaled *down* by the slowest selectable playback rate: `30_000 * 0.5`. (0.5x is offered for audiobooks, which share this load control and which `DefaultLoadControl` does not scale for. Media time converts back to wall clock by dividing: `15_000 / 0.5 = 30_000`.) +- **Depth bounds:** requested floor `20_000` ms, ceiling `180_000` ms. The floor is where depth starts, not a guarantee: a known bitrate whose budget funds less than 20s yields the smaller number rather than a claimed depth memory cannot hold. +- **Startup:** `bufferForPlaybackMs = 2_000`, `bufferForPlaybackAfterRebufferMs = 5_000`. +- **No user setting, no server-driven wire value.** `PlaybackBufferMode` and its `fromWire` are deleted, not repurposed. +- **No transcode/HLS special case.** One policy; throughput governs. The server's `TranscodeThrottler` owns the transcode-ahead ceiling. +- Package root is `org.prairieserver.prairie`; buffer code lives in `org.prairieserver.prairie.common.player`. +- Build/test: `./gradlew :android-shared:testDebugUnitTest` for these tests; `./gradlew :androidApp:assembleDebug` and `./gradlew :androidTvApp:assembleDebug` must both still build (the module is shared). +- Per repo guidelines, add focused tests for the high-risk behaviour only — do not blanket-test UI or trivial changes. +- Commit per task. Push to `origin` (the RXWatcher fork), never a PR against Prairie-Server without being asked. + +--- + +### Task 1: Derive `maxBufferMs` and delete the dead mode enum + +This task alone fixes the reported dropped connections. + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicy.kt` +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SiloPlayerFactory.kt:318-323` +- Test: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicyTest.kt` + +**Interfaces:** +- Produces: `PlaybackBufferPolicy.forConditions(deviceProfile: PlaybackBufferDeviceProfile): PlaybackBufferPolicy`, plus companion constants `MAX_LOAD_IDLE_MS = 15_000`, `MIN_DEPTH_MS = 20_000`, `MAX_DEPTH_MS = 180_000`, `ASSUMED_PROXY_SEND_TIMEOUT_MS = 60_000`. The `PlaybackBufferPolicy` data class keeps its existing six fields unchanged. +- Removes: `PlaybackBufferMode` (whole enum, including `fromWire`) and `PlaybackBufferPolicy.forMode(...)`. `PlaybackBufferDeviceProfile` stays exactly as it is. + +- [ ] **Step 1: Write the failing tests** + +Replace the whole body of `PlaybackBufferPolicyTest` (its existing tests reference `forMode`/`PlaybackBufferMode`, which this task deletes): + +```kotlin +package org.prairieserver.prairie.common.player + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PlaybackBufferPolicyTest { + + private val roomy = PlaybackBufferDeviceProfile(memoryClassMb = 512, isLowRamDevice = false) + private val lowRam = PlaybackBufferDeviceProfile(memoryClassMb = 96, isLowRamDevice = true) + + // The load control stops reading the socket once the buffer reaches + // maxBufferMs and does not resume until it drains below minBufferMs, so + // this gap IS how long the connection sits idle. An upstream proxy with a + // 60s send timeout drops it if the gap approaches that. This is the + // property the whole design exists to guarantee. + @Test + fun `idle window is bounded for every device profile`() { + listOf(roomy, lowRam, PlaybackBufferDeviceProfile.Unknown).forEach { profile -> + val policy = PlaybackBufferPolicy.forConditions(profile) + assertEquals( + PlaybackBufferPolicy.MAX_LOAD_IDLE_MS, + policy.maxBufferMs - policy.minBufferMs, + "idle window for $profile", + ) + } + } + + @Test + fun `idle window stays well under the proxy send timeout it guards against`() { + assertTrue( + PlaybackBufferPolicy.MAX_LOAD_IDLE_MS * 2 <= + PlaybackBufferPolicy.ASSUMED_PROXY_SEND_TIMEOUT_MS, + "idle window should keep a wide margin below the assumed timeout", + ) + } + + @Test + fun `playback starts on a small cushion and recovers quickly after a stall`() { + val policy = PlaybackBufferPolicy.forConditions(roomy) + assertEquals(2_000, policy.bufferForPlaybackMs) + assertEquals(5_000, policy.bufferForPlaybackAfterRebufferMs) + } + + @Test + fun `depth stays within the declared floor and ceiling`() { + listOf(roomy, lowRam, PlaybackBufferDeviceProfile.Unknown).forEach { profile -> + val policy = PlaybackBufferPolicy.forConditions(profile) + assertTrue(policy.minBufferMs >= PlaybackBufferPolicy.MIN_DEPTH_MS, "floor for $profile") + assertTrue(policy.minBufferMs <= PlaybackBufferPolicy.MAX_DEPTH_MS, "ceiling for $profile") + } + } + + @Test + fun `startup thresholds never exceed the depth the policy asks for`() { + listOf(roomy, lowRam, PlaybackBufferDeviceProfile.Unknown).forEach { profile -> + val policy = PlaybackBufferPolicy.forConditions(profile) + assertTrue(policy.bufferForPlaybackMs <= policy.minBufferMs, "start for $profile") + assertTrue( + policy.bufferForPlaybackAfterRebufferMs <= policy.minBufferMs, + "rebuffer for $profile", + ) + } + } +} +``` + +- [ ] **Step 2: Run the tests and watch them fail** + +Run: `./gradlew :android-shared:testDebugUnitTest --tests '*PlaybackBufferPolicyTest*'` +Expected: FAIL — `forConditions` and the constants are unresolved references. + +- [ ] **Step 3: Implement** + +Replace the contents of `PlaybackBufferPolicy.kt` with: + +```kotlin +package org.prairieserver.prairie.common.player + +/** + * Buffering policy derived from what the player can observe. There is no user + * setting and no server-supplied mode: the numbers follow from the device and + * the stream. + */ +data class PlaybackBufferPolicy( + val minBufferMs: Int, + val maxBufferMs: Int, + val bufferForPlaybackMs: Int, + val bufferForPlaybackAfterRebufferMs: Int, + val targetBufferBytes: Int, + val prioritizeTimeOverSizeThresholds: Boolean, +) { + companion object { + /** + * How long the load control may stop reading the socket. + * + * DefaultLoadControl fills to maxBufferMs, then requests nothing until + * the buffer drains below minBufferMs — so the gap between them is + * literally how long the connection sits idle. Upstream proxies close + * an idle response body: nginx's send_timeout defaults to 60s. The old + * hand-written 50s/120s pair left a 70s gap and dropped the connection + * every time the buffer filled on a long direct-play file. + * + * maxBufferMs is therefore never written by hand; it is always + * minBufferMs + this. Depth can grow without ever widening the window. + */ + const val MAX_LOAD_IDLE_MS = 15_000 + + /** + * The timeout MAX_LOAD_IDLE_MS is chosen to stay clear of. The window is + * this budgeted down to 30s of wall clock and then multiplied by + * SLOWEST_PLAYBACK_SPEED to express it in media time, + * because the invariant is in media time and a proxy measures wall clock. + */ + const val ASSUMED_PROXY_SEND_TIMEOUT_MS = 60_000 + + /** The depth the policy asks for before the memory budget has its say. */ + const val MIN_DEPTH_MS = 20_000 + + /** + * Never buffer more than this even when memory allows. Past a few + * minutes we are mostly prefetching content the viewer may seek away + * from — wasted bandwidth, and wasted allowance on mobile data. + */ + const val MAX_DEPTH_MS = 180_000 + + private const val START_MS = 2_000 + + /** + * After a stall the viewer is watching a spinner, so the cushion we + * rebuild before resuming is deliberately small. + */ + private const val REBUFFER_MS = 5_000 + + fun forConditions( + deviceProfile: PlaybackBufferDeviceProfile = PlaybackBufferDeviceProfile.Unknown, + ): PlaybackBufferPolicy { + val depthMs = MAX_DEPTH_MS + return PlaybackBufferPolicy( + minBufferMs = depthMs, + maxBufferMs = depthMs + MAX_LOAD_IDLE_MS, + bufferForPlaybackMs = START_MS, + bufferForPlaybackAfterRebufferMs = REBUFFER_MS, + targetBufferBytes = memoryBudgetBytes(deviceProfile), + prioritizeTimeOverSizeThresholds = false, + ) + } + + /** + * The byte ceiling this device can afford. PrairieLoadControl sizes the + * real target from the stream's bitrate and clamps it to this. + */ + internal fun memoryBudgetBytes(deviceProfile: PlaybackBufferDeviceProfile): Int = when { + deviceProfile.isLowRamDevice -> 48 * MIB + deviceProfile.memoryClassMb <= 0 -> 48 * MIB + deviceProfile.memoryClassMb < 192 -> 48 * MIB + deviceProfile.memoryClassMb < 384 -> 96 * MIB + else -> 160 * MIB + } + + private const val MIB = 1024 * 1024 + } +} + +data class PlaybackBufferDeviceProfile( + val memoryClassMb: Int, + val isLowRamDevice: Boolean, +) { + companion object { + val Unknown = PlaybackBufferDeviceProfile(memoryClassMb = 0, isLowRamDevice = false) + } +} +``` + +Then update the call site in `SiloPlayerFactory.kt` (around line 318). Replace the `PlaybackBufferPolicy.forMode(PlaybackBufferMode.Balanced, playbackBufferDeviceProfile())` call and its preceding comment with: + +```kotlin + // Start on a small cushion and keep filling in the background. Depth is + // bounded by the device's memory budget in PrairieLoadControl; the gap + // between min and max is fixed so the connection is never idle long + // enough for an upstream proxy to close it. + val bufferPolicy = PlaybackBufferPolicy.forConditions(playbackBufferDeviceProfile()) +``` + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `./gradlew :android-shared:testDebugUnitTest --tests '*PlaybackBufferPolicyTest*'` +Expected: PASS, 5 tests. + +- [ ] **Step 5: Confirm both apps still build** + +Run: `./gradlew :androidApp:assembleDebug :androidTvApp:assembleDebug` +Expected: BUILD SUCCESSFUL. If either fails on an unresolved `PlaybackBufferMode`, there is a second reference to the deleted enum — find it with `grep -rn "PlaybackBufferMode" --include="*.kt" .` and remove it. + +- [ ] **Step 6: Commit** + +```bash +git add android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicy.kt \ + android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SiloPlayerFactory.kt \ + android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicyTest.kt +git commit -m "fix(playback): bound the load-idle window so proxies stop dropping the connection" +``` + +--- + +### Task 2: Fit depth to the memory budget instead of letting bytes silently truncate it + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControl.kt` +- Test: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControlTest.kt` + +**Interfaces:** +- Consumes: `PlaybackBufferPolicy.MIN_DEPTH_MS`, `PlaybackBufferPolicy.MAX_LOAD_IDLE_MS` (Task 1); the existing internal helpers `selectBufferSizingBitrateBps(...)` and `calculateBitrateTargetBufferBytes(...)`, both unchanged. +- Produces: `internal fun affordableDepthMs(desiredDepthMs: Int, selectedBitrateBps: Long?, budgetBytes: Int, minimumDepthMs: Int): Int` — the depth the budget can actually fund, never above `desiredDepthMs`. `minimumDepthMs` is the floor only on the unknown-bitrate path, where there is nothing to size from; with a known bitrate the helper returns the true affordable depth even when that is below `minimumDepthMs`, since claiming the floor would reinstate exactly the silent overrun this task removes. + +**Context an implementer needs:** today `calculateTargetBufferBytes` clamps bytes to the budget and stops there, so on a 60 Mbps remux the loader quietly stops at whatever the cap affords (about 5s on a low-RAM device) while the policy still claims a much larger depth. The fix is not to raise the cap — memory is genuinely finite — but to make the reduction explicit, so the resulting depth is a number the code chose rather than an accident. + +- [ ] **Step 1: Write the failing tests** + +Append to `PrairieLoadControlTest`: + +```kotlin + @Test + fun `depth follows the budget honestly, even below the floor`() { + // 60 Mbps against a 48 MiB budget: accounting for the same 15% + // overhead margin calculateBitrateTargetBufferBytes applies when it + // turns this depth back into bytes, the budget only really affords + // ~5.8s. The requested 180s cannot be held, and neither can the 20s + // floor — the budget wins over the floor because a false, rounded-up + // report would be worse than an honest shortfall. + val depth = + affordableDepthMs( + desiredDepthMs = 180_000, + selectedBitrateBps = 60_000_000L, + budgetBytes = 48 * 1024 * 1024, + minimumDepthMs = 20_000, + ) + + assertTrue(depth < 20_000, "expected reduction below the floor, got $depth") + assertEquals(5_835, depth, "should report the honest budget-derived value") + } + + @Test + fun `depth is left alone when the budget can fund it`() { + // 5 Mbps against 160 MiB: ~268s available, more than the 180s asked for. + val depth = + affordableDepthMs( + desiredDepthMs = 180_000, + selectedBitrateBps = 5_000_000L, + budgetBytes = 160 * 1024 * 1024, + minimumDepthMs = 20_000, + ) + + assertEquals(180_000, depth) + } + + @Test + fun `depth falls back to the request when the bitrate is unknown`() { + val depth = + affordableDepthMs( + desiredDepthMs = 120_000, + selectedBitrateBps = null, + budgetBytes = 96 * 1024 * 1024, + minimumDepthMs = 20_000, + ) + + assertEquals(120_000, depth) + } + + @Test + fun `reducing depth never widens the idle window`() { + // The invariant has to survive the reduction: whatever depth the budget + // affords, max is still exactly one idle window above it. + val depth = + affordableDepthMs( + desiredDepthMs = 180_000, + selectedBitrateBps = 80_000_000L, + budgetBytes = 48 * 1024 * 1024, + minimumDepthMs = PlaybackBufferPolicy.MIN_DEPTH_MS, + ) + val max = depth + PlaybackBufferPolicy.MAX_LOAD_IDLE_MS + + assertEquals(PlaybackBufferPolicy.MAX_LOAD_IDLE_MS, max - depth) + } +``` + +Add `import org.junit.Assert.assertTrue` to the file's imports. + +- [ ] **Step 2: Run the tests and watch them fail** + +Run: `./gradlew :android-shared:testDebugUnitTest --tests '*PrairieLoadControlTest*'` +Expected: FAIL — `affordableDepthMs` is an unresolved reference. + +- [ ] **Step 3: Implement** + +Add to `PrairieLoadControl.kt`, beside the other internal helpers: + +```kotlin +/** + * The forward buffer this device can actually hold at this bitrate. + * + * Memory is finite, so a high-bitrate stream genuinely cannot be buffered as + * deeply as a low-bitrate one. Computing that reduction here — rather than + * letting the byte clamp truncate the buffer wherever it happens to land — + * means the resulting depth is a number the code chose and can be reasoned + * about, and it keeps maxBufferMs one idle window above a depth that is real. + * + * An unknown bitrate leaves the request untouched; the byte clamp still + * applies downstream. + */ +internal fun affordableDepthMs( + desiredDepthMs: Int, + selectedBitrateBps: Long?, + budgetBytes: Int, + minimumDepthMs: Int, +): Int { + val bitrate = selectedBitrateBps?.takeIf { it > 0L } + ?: return desiredDepthMs.coerceAtLeast(minimumDepthMs) + // The 115/100 mirrors the overhead margin calculateBitrateTargetBufferBytes + // multiplies back in when it turns a depth into bytes. Without it, a + // budget-derived depth still produces a byte figure that overshoots the + // budget once that margin is applied and clamps back to the ceiling, + // erasing the depth's effect on the byte target entirely. + val affordableMs = budgetBytes.toLong() * 8L * 1_000L * 100L / (bitrate * 115L) + // Deliberately NOT coerced up to minimumDepthMs: with a known bitrate the + // honest budget-derived depth wins over the floor, since claiming a depth + // the loader cannot hold is the exact silent overrun this task removes. + // minimumDepthMs bounds only the unknown-bitrate branch above. + return affordableMs.coerceAtMost(desiredDepthMs.toLong()).toInt() +} +``` + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `./gradlew :android-shared:testDebugUnitTest --tests '*PrairieLoadControlTest*'` +Expected: PASS — the four new tests plus the existing bitrate-selection ones. + +- [ ] **Step 5: Commit** + +```bash +git add android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControl.kt \ + android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControlTest.kt +git commit -m "feat(playback): fit buffer depth to the device memory budget" +``` + +--- + +### Task 3: Apply the affordable depth to the live load control + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControl.kt` +- Test: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControlTest.kt` + +**Interfaces:** +- Consumes: `affordableDepthMs(...)` (Task 2), `PlaybackBufferPolicy` (Task 1). +- Produces: `PrairieLoadControl.currentDepthMs(): Int` — the depth most recently computed from observed bitrate, for tests and diagnostics. Defaults to the policy's `minBufferMs` before any track selection has happened. + +**Context an implementer needs:** `DefaultLoadControl` reads its min/max durations from constructor arguments and does not re-read them, so the depth reduction cannot change the running loader's time thresholds. It can and must still change the *byte* target, which is what actually stops the loader. `currentDepthMs()` exists so the reduction is observable rather than implicit — do not attempt to mutate the superclass's durations. + +- [ ] **Step 1: Write the failing test** + +Append to `PrairieLoadControlTest`: + +```kotlin + @Test + fun `byte target follows the affordable depth rather than the requested one`() { + // A 40 Mbps stream on a 48 MiB budget can hold ~10s, not the 180s the + // policy asks for. The byte target must reflect the affordable depth, + // and must never exceed the budget. + val budgetBytes = 48 * 1024 * 1024 + val depth = + affordableDepthMs( + desiredDepthMs = PlaybackBufferPolicy.MAX_DEPTH_MS, + selectedBitrateBps = 40_000_000L, + budgetBytes = budgetBytes, + minimumDepthMs = PlaybackBufferPolicy.MIN_DEPTH_MS, + ) + + val bytes = + calculateBitrateTargetBufferBytes( + selectedBitrateBps = 40_000_000L, + desiredForwardBufferMs = depth, + minimumBytes = PrairieLoadControl.MIN_TARGET_BUFFER_BYTES, + maximumBytes = budgetBytes, + unknownBitrateFallbackBytes = budgetBytes, + ) + + assertTrue(bytes <= budgetBytes, "byte target $bytes exceeded budget $budgetBytes") + assertTrue(bytes >= PrairieLoadControl.MIN_TARGET_BUFFER_BYTES, "byte target below floor") + } +``` + +- [ ] **Step 2: Run the test and watch it fail** + +Run: `./gradlew :android-shared:testDebugUnitTest --tests '*PrairieLoadControlTest*'` +Expected: FAIL — `MIN_TARGET_BUFFER_BYTES` is `internal` inside a companion that the test can reach, but `calculateTargetBufferBytes` does not yet size from an affordable depth. If it compiles and passes immediately, the sizing path was already correct; still complete step 3 so the running loader uses it. + +- [ ] **Step 3: Implement** + +Replace `PrairieLoadControl`'s `calculateTargetBufferBytes` override and add the depth field: + +```kotlin + @Volatile private var depthMs: Int = policy.minBufferMs + + /** The forward buffer the memory budget currently affords, in ms. */ + internal fun currentDepthMs(): Int = depthMs + + override fun calculateTargetBufferBytes( + parameters: LoadControl.Parameters, + trackSelections: Array, + ): Int { + val selectedBitrateBps = + selectBufferSizingBitrateBps( + trackSelections.mapNotNull { selection -> + selection?.let { + BufferSizingTrackBitrates( + averageBitrateBps = it.selectedFormat.averageBitrate, + peakBitrateBps = it.selectedFormat.peakBitrate, + latestNetworkEstimateBps = it.latestBitrateEstimate, + ) + } + }, + ) + val fallback = super.calculateTargetBufferBytes(parameters, trackSelections) + val affordableMs = + affordableDepthMs( + desiredDepthMs = policy.minBufferMs, + selectedBitrateBps = selectedBitrateBps, + budgetBytes = policy.targetBufferBytes, + minimumDepthMs = PlaybackBufferPolicy.MIN_DEPTH_MS, + ) + depthMs = affordableMs + return calculateBitrateTargetBufferBytes( + selectedBitrateBps = selectedBitrateBps, + desiredForwardBufferMs = affordableMs, + minimumBytes = MIN_TARGET_BUFFER_BYTES, + maximumBytes = policy.targetBufferBytes, + unknownBitrateFallbackBytes = fallback, + ) + } +``` + +- [ ] **Step 4: Run the full module test suite** + +Run: `./gradlew :android-shared:testDebugUnitTest` +Expected: PASS, no regressions in the existing player tests. + +- [ ] **Step 5: Confirm both apps build** + +Run: `./gradlew :androidApp:assembleDebug :androidTvApp:assembleDebug` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 6: Commit** + +```bash +git add android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControl.kt \ + android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControlTest.kt +git commit -m "feat(playback): size the byte target from the affordable buffer depth" +``` + +--- + +## Self-review notes (already applied) + +- Spec coverage: the invariant and enum deletion → Task 1; memory-governed depth with floor/ceiling → Tasks 2 and 3; startup/rebuffer numbers → Task 1; no-transcode-special-case and no-user-setting are satisfied by never introducing them. +- Throughput-driven depth is represented by the existing `latestBitrateEstimate` fallback inside `selectBufferSizingBitrateBps`, which already prefers measured network throughput when the container declares no bitrate. No separate task: adding a second throughput mechanism would duplicate it. +- Type consistency: `affordableDepthMs` has one signature, used identically in Tasks 2 and 3; `PlaybackBufferPolicy.forConditions` takes only a device profile in both Task 1 and its call site. +- `MIN_TARGET_BUFFER_BYTES` stays `internal const` on `PrairieLoadControl`'s companion, unchanged from today, so the Task 3 test can reference it. diff --git a/docs/superpowers/specs/2026-05-23-android-tv-parity-rework-design.md b/docs/superpowers/specs/2026-05-23-android-tv-parity-rework-design.md index 06119cf3c..eb980d3e8 100644 --- a/docs/superpowers/specs/2026-05-23-android-tv-parity-rework-design.md +++ b/docs/superpowers/specs/2026-05-23-android-tv-parity-rework-design.md @@ -353,7 +353,7 @@ On `init`, kicks off `deviceLoginRepository.initiate()` + starts polling. Either ### C.6 — Server-setup screen -Apple's `TVServerSetupView` also exposes a setup QR. This sub-project adds the equivalent affordance to `TvServerSetupScreen` (QR encoding `silo-setup://server` or whatever Apple emits — verify against `TVServerSetupView`). User scans, phone confirms, server URL flows back via the same device-grant pattern. +Apple's `TVServerSetupView` also exposes a setup QR. This sub-project adds the equivalent affordance to `TvServerSetupScreen` (QR encoding `prairie-setup://server` or whatever Apple emits — verify against `TVServerSetupView`). User scans, phone confirms, server URL flows back via the same device-grant pattern. If verifying this turns out to require server changes beyond what's in scope, **split out** as a follow-up. The credential-entry path on `TvServerSetupScreen` remains as the always-available fallback. diff --git a/docs/superpowers/specs/2026-06-09-android-media-surfaces-design.md b/docs/superpowers/specs/2026-06-09-android-media-surfaces-design.md index 97f223a76..dc7606630 100644 --- a/docs/superpowers/specs/2026-06-09-android-media-surfaces-design.md +++ b/docs/superpowers/specs/2026-06-09-android-media-surfaces-design.md @@ -2,7 +2,7 @@ ## Context -The deployed server at `root@silo-new:/opt/prairie-server` now exposes first-class audiobook and ebook catalog contracts. Android currently has partial audiobook and book scaffolding, but several assumptions are stale: old metadata expects fields such as `file_url`, while the server now exposes media through `versions`, `audiobook`, `ebook`, and dedicated ebook reader endpoints. +The deployed server at `root@prairie-new:/opt/prairie-server` now exposes first-class audiobook and ebook catalog contracts. Android currently has partial audiobook and book scaffolding, but several assumptions are stale: old metadata expects fields such as `file_url`, while the server now exposes media through `versions`, `audiobook`, `ebook`, and dedicated ebook reader endpoints. This design adds audiobooks to Android mobile and Android TV, adds ebooks to Android mobile, and hides ebooks from Android TV. diff --git a/docs/superpowers/specs/2026-06-09-android-requests-design.md b/docs/superpowers/specs/2026-06-09-android-requests-design.md index 26619ffd3..0f1d4fa6c 100644 --- a/docs/superpowers/specs/2026-06-09-android-requests-design.md +++ b/docs/superpowers/specs/2026-06-09-android-requests-design.md @@ -6,7 +6,7 @@ Bring the server media-request system to Android clients with a shared Kotlin co ## Source Contract -The server source of truth is the current `/opt/prairie-server` working tree on `root@silo-new`, especially: +The server source of truth is the current `/opt/prairie-server` working tree on `root@prairie-new`, especially: - `internal/api/router.go` - `internal/api/handlers/requests.go` diff --git a/docs/superpowers/specs/2026-06-12-manga-pr138-client-audit.md b/docs/superpowers/specs/2026-06-12-manga-pr138-client-audit.md index e8804e117..7f5423767 100644 --- a/docs/superpowers/specs/2026-06-12-manga-pr138-client-audit.md +++ b/docs/superpowers/specs/2026-06-12-manga-pr138-client-audit.md @@ -15,7 +15,7 @@ - Extended: `GET /catalog/items/{id}` for a manga series carries `manga: { chapters: MangaChapter[] }` — `{content_id, title, chapter_index?: float, volume?: string, read: bool, progress?: 0..1, poster_url?}`, ordered `chapter_index NULLS LAST, sort_title`, read state derived from existing ebook progress. - Chapter item detail now populates `series_id`/`series_title` with the owning manga series (back-navigation + continue-reading collapse). - **Progress is the existing ebook API unchanged** (`GET/PUT /ebooks/{content_id}/progress`, fraction 0..1, per chapter). `read = progress >= finished-threshold`. Mark read/unread reuses the watched-state mutation with `type:"ebook"` per chapter (series-level with `type:"manga"`). -- Status badge is **publication status, not user reading status**: reuses `show_status` with values `Ongoing|Completed|Hiatus|Cancelled|Upcoming` (sourced via the external `silo-plugin-manga-metadata` plugin; requires plugin-sdk v0.7.0 — treat as optional). +- Status badge is **publication status, not user reading status**: reuses `show_status` with values `Ongoing|Completed|Hiatus|Cancelled|Upcoming` (sourced via the external `prairie-plugin-manga-metadata` plugin; requires plugin-sdk v0.7.0 — treat as optional). ## Web reference UX diff --git a/docs/superpowers/specs/2026-06-12-oauth-signin-mobile-design.md b/docs/superpowers/specs/2026-06-12-oauth-signin-mobile-design.md index a0ad114da..966cd733c 100644 --- a/docs/superpowers/specs/2026-06-12-oauth-signin-mobile-design.md +++ b/docs/superpowers/specs/2026-06-12-oauth-signin-mobile-design.md @@ -21,7 +21,7 @@ OAuth on prairie-server is **plugin-driven generic OIDC** — there are no built Because the server completes OAuth by redirecting a **browser** to `{PublicURL}/login/oauth-complete?code=…` and offers no app-scheme/PKCE redirect, the client cannot receive the completion through a Chrome Custom Tab without a server-side App Links file per self-hosted domain (rejected: unreliable, larger scope). A **WebView gives full URL interception**, which is exactly what is needed: load the IdP authorize URL, watch each navigation, and capture the completion redirect's `code`. This is the approved mechanism. -**Known limitation:** WebView-based OAuth is fine for self-hosted OIDC (what silo uses) but Google-branded sign-in disallows embedded WebViews (`disallowed_useragent`). Documented, not worked around. +**Known limitation:** WebView-based OAuth is fine for self-hosted OIDC (what prairie uses) but Google-branded sign-in disallows embedded WebViews (`disallowed_useragent`). Documented, not worked around. ## Client design diff --git a/docs/superpowers/specs/2026-06-15-best-of-source-borrow-map-design.md b/docs/superpowers/specs/2026-06-15-best-of-source-borrow-map-design.md index 739b386e3..251957e01 100644 --- a/docs/superpowers/specs/2026-06-15-best-of-source-borrow-map-design.md +++ b/docs/superpowers/specs/2026-06-15-best-of-source-borrow-map-design.md @@ -8,20 +8,20 @@ ## Purpose This document mines the reference apps in `/Users/jimcole/source` and maps their best -product behavior and engineering patterns onto silo's current subsystems. It is the +product behavior and engineering patterns onto prairie's current subsystems. It is the concrete companion to the approved best-of-source roadmap: where the roadmap says *what* to borrow and in what order, this says *exactly which pattern, from which file, applied -where in silo, at what effort, under what license, with what Android-7 caveat*. +where in prairie, at what effort, under what license, with what Android-7 caveat*. Approach (agreed): **hybrid, prioritized by gap.** Deep code-level extraction for Reading (ebook/reflow, comics/manga, PDF), Audiobook, and the Video player; survey-level (gaps only) for Watch-Together, Notifications, Requests, and Admin. Each was mined by an -agent reading the mapped reference apps against silo's current code. +agent reading the mapped reference apps against prairie's current code. ## How to read this - Effort: **S** = ≤1 day, **M** = a few days, **L** = a week+ / new subsystem. -- Every borrowed idea cites a real source file and the silo file it applies to. +- Every borrowed idea cites a real source file and the prairie file it applies to. - Correctness items overlapping the playback/reader review (2026-06-15) are flagged. - Constraints carried throughout: **Android 7 / API 24 hard floor** and **AGPLv3**. @@ -36,16 +36,16 @@ debugging), not as gating rules. The only reason to *reimplement* rather than paste, then, is **engineering fit**, and it still applies in specific cases: -- Different UI stack — silo is **Compose/KMP-first**; the Jellyfin clients, mihon, Kotatsu, +- Different UI stack — prairie is **Compose/KMP-first**; the Jellyfin clients, mihon, Kotatsu, document-viewer, etc. are XML View + RecyclerView/ViewPager. Pasting their view holders imports a parallel UI stack. - Parallel infrastructure — don't drag in a second DI/preferences system (mihon's Injekt, - others' DataStore wrappers) when silo already has its own. + others' DataStore wrappers) when prairie already has its own. - Native packaging — MuPDF/pdfium, libmobi, MPV add NDK builds, ABI/`.so` weight, and API24 risk; those are *architecture decisions*, not free copies, regardless of license. So: **copy whatever is a clean fit; reimplement only where the source's architecture -clashes with silo's.** The "what NOT to borrow" notes below now reflect engineering fit, +clashes with prairie's.** The "what NOT to borrow" notes below now reflect engineering fit, not licensing. --- @@ -121,7 +121,7 @@ They de-risk everything built on top. ### Best-in-class reference(s) and why -- **readest** (AGPL-3.0) — most directly transferable: foliate-js WebView reader whose **document model, CFI locator, and three-tier settings hierarchy** map ~1:1 onto silo's WebView-reflow architecture. Primary mine. +- **readest** (AGPL-3.0) — most directly transferable: foliate-js WebView reader whose **document model, CFI locator, and three-tier settings hierarchy** map ~1:1 onto prairie's WebView-reflow architecture. Primary mine. - **book-story** (GPL-3.0) — best **Compose-native shell and settings UX** (type-safe DataStore DSL, debounced progress persistence, nested-TOC drawer, font registry). *Do not* borrow its rendering path (it discards EPUB CSS). - **koreader** (AGPL-3.0) — **typography depth** (language-keyed hyphenation, margin presets, CSS-tweak taxonomy) as data tables/UX, not the C++ engine. - **LibreraReader** (GPL-3.0; bundles LGPL libmobi) — the concrete **MOBI/AZW native-parse** path (libmobi → EPUB conversion, then reuse the EPUB pipeline). @@ -145,12 +145,12 @@ They de-risk everything built on top. ### Concrete adoptable patterns -| Idea | Source location | Where in silo | Effort | License | A7 caveat | +| Idea | Source location | Where in prairie | Effort | License | A7 caveat | |---|---|---|---|---|---| | Element-level locator (CSS-selector/text-quote anchor) alongside section+fraction | readest `utils/cfi.ts`, `services/nav/locations.ts` | `reflow/ReflowLocator.kt`, `paginator.js` (emit nearest-element selector on relocate) | M | AGPL design, reimplement | `elementFromPoint`+`querySelector` OK on API24 WebView; avoid `:has()` | | Char/byte-offset location map for accurate book progress | readest `bakeLocationsAndCfis` (SIZE_PER_LOC=2500) | replace `SectionWeights.kt` estimate with cumulative offset table | S | AGPL design | none | | Three-tier settings (global < per-book deltas < session) | readest `types/book.ts` override flags | `ReaderControls.kt` + `EbookLocalStateStore.kt` | M | AGPL design | none | -| Type-safe DataStore settings DSL | book-story `data/settings/SettingsManager.kt` | silo display-prefs persistence (absent today) | M | GPL-3.0 — attribute if copied | DataStore fine on API24 | +| Type-safe DataStore settings DSL | book-story `data/settings/SettingsManager.kt` | prairie display-prefs persistence (absent today) | M | GPL-3.0 — attribute if copied | DataStore fine on API24 | | Debounced (300ms) progress persistence | book-story `ReaderModel.updateProgress()` | `ReflowableReader` locator callback / `ReaderViewModel.kt` | S | GPL-3.0 pattern | none | | Font registry + font-family picker | book-story `ui/reader/data/ReaderData.kt` | extend `ReaderDisplaySettings`; wire `ReflowStyle.kt:23` | M | GPL-3.0 pattern | bundle static weights; variable fonts API26+ | | Nested-TOC drawer w/ auto-expand current | book-story `ui/reader/ReaderChaptersDrawer.kt` | `ReaderShell.kt` | S | GPL-3.0 pattern | none | @@ -163,7 +163,7 @@ They de-risk everything built on top. ### What NOT to borrow / risks -- Don't adopt book-story's CSS-discarding native-text parser (silo's WebView correctly preserves publisher CSS) — borrow its shell/settings/state, not its parser. (JellyBook is thin; ideas only.) +- Don't adopt book-story's CSS-discarding native-text parser (prairie's WebView correctly preserves publisher CSS) — borrow its shell/settings/state, not its parser. (JellyBook is thin; ideas only.) - Don't chase koreader's CREngine; only its Lua data tables/UX taxonomy are practical. - Full EPUB-CFI is heavy — a lighter element-selector + text-quote anchor gives ~90% of resume/bookmark stability for far less. - libmobi adds an NDK build + binary size + maintenance — justify only if MOBI/AZW is a real need; otherwise keep graceful external-open. @@ -199,7 +199,7 @@ They de-risk everything built on top. ### Concrete adoptable patterns -| Idea | Source location | Where in silo | Effort | License | A7 caveat | +| Idea | Source location | Where in prairie | Effort | License | A7 caveat | |---|---|---|---|---|---| | `Viewer` interface + per-mode impls (L2R/R2L/Vertical/Webtoon) by object | mihon `viewer/Viewer.kt:12`, `pager/PagerViewers.kt` | replace `ComicReader.kt` monolith; `ComicViewer` + manga/webtoon impls | M | Apache-2.0 — reimplement, attribute | none | | Config vs tap-navigation split (reactive config; fractional `RectF`→region) | mihon `ViewerConfig.kt`, `ViewerNavigation.kt:44-53` | new `ComicReaderConfig` from `ReaderDisplaySettings` | M | Apache-2.0 | none | @@ -209,7 +209,7 @@ They de-risk everything built on top. | `ZoomMode` (FIT_WIDTH/HEIGHT/SCREEN/ORIGINAL) + zoom/pan/double-tap via SSIV | Kotatsu `core/model/ZoomMode.kt`, `ui/pager/standard/PageHolder.kt`; mihon `ReaderPageImageView.kt` | replace `Image`/`ContentScale.Fit` (`ComicReader.kt:263-270`) with SSIV in `AndroidView` | L | GPL/Apache | SSIV region/tile decode = the A7-safe path for large/zoomed pages | | Adaptive decode: RGB_565 + region decoder + downsample by free-RAM | Kotatsu `BasePageHolder`, `core/image/BitmapDecoderCompat.kt` | extend `decodeComicPageBitmap` (`:299-314`) | M | GPL-3.0 | targets API24 OOM; `ImageDecoder` is API28+ — keep BitmapFactory fallback | | Chapter/issue stitching (prev/transition/curr/transition/next) + range preload | mihon `PagerViewerAdapter.kt:47-114` | new series-aware layer above `ReaderViewModel` | L | Apache-2.0 | needs backend series API | -| Natural/numeric page sort | (silo bug) | `listComicArchivePages` (`:343-358`) | S | n/a | none | +| Natural/numeric page sort | (prairie bug) | `listComicArchivePages` (`:343-358`) | S | n/a | none | | Double-page spread pairing (landscape) — later | komikku `PagerViewerAdapter` joinedItems | future `PageLayout` mode | L | Apache-2.0 | two bitmaps at once → watch API24 memory | | Rotation-restore guard (suppress spurious page events) | komikku `viewer/pager/Pager.kt` | pager-state restore | S | Apache-2.0 | none | @@ -218,11 +218,11 @@ They de-risk everything built on top. - Don't fork the View-based viewers wholesale (XML ViewPager/RecyclerView + SSIV); borrow the *abstractions* and reimplement in Compose, using thin `AndroidView`/SSIV wrappers only where zoom demands it. - Don't pull a native unrar/7z lib for CBR/CB7 in the first slice — keep external-only. - **API24 decode memory is the dominant risk:** mandatory RAM-gated prefetch, bounded concurrent decodes, RGB_565 + region decoding via SSIV, free-RAM-tied downsample. `ImageDecoder` paths are API28+ — keep BitmapFactory fallback. -- Don't copy mihon's Injekt/preferences plumbing; route config through silo's existing `ReaderDisplaySettings` + `EbookLocalStateStore`. +- Don't copy mihon's Injekt/preferences plumbing; route config through prairie's existing `ReaderDisplaySettings` + `EbookLocalStateStore`. ## PDF / Fixed-Document Reader -silo renders fixed documents with Android's built-in `PdfRenderer` (no native MuPDF/pdfium). The references all sit on MuPDF (GPL/AGPL) — adopt **algorithms/patterns**, not their JNI codecs. +prairie renders fixed documents with Android's built-in `PdfRenderer` (no native MuPDF/pdfium). The references all sit on MuPDF (GPL/AGPL) — adopt **algorithms/patterns**, not their JNI codecs. ### Best-in-class reference(s) and why @@ -236,7 +236,7 @@ silo renders fixed documents with Android's built-in `PdfRenderer` (no native Mu - **LRU page cache + recycle-on-evict** — document-viewer `DecodeServiceBase.java:385-417`; Librera `:63-82`. - **Bitmap pool/reuse** — document-viewer `BitmapManager.java:81-257` (match w/h/config before alloc, cap `maxMemory()/2`). - **OOM safety net** — document-viewer `DecodeServiceBase.java:215-228`; Librera `:291-369`: catch `OutOfMemoryError`, clear cache, recycle, abort task — never crash. -- **Native page lifecycle guards** — Librera `MuPdfPage.java` `isRecycled()` + global `TempHolder.lock`; document-viewer `MuPdfPage.java:93-113` synchronized recycle. (Validates silo's own concern from two codebases.) +- **Native page lifecycle guards** — Librera `MuPdfPage.java` `isRecycled()` + global `TempHolder.lock`; document-viewer `MuPdfPage.java:93-113` synchronized recycle. (Validates prairie's own concern from two codebases.) - **Fit modes** — document-viewer `SinglePageController.java:253-268`; koreader `readerzooming.lua:522-593` (9 modes). - **Content auto-crop** — koreader `pdfdocument.lua:155-176` + `koptinterface.lua:191-237` (validates bbox > 10% of page). `PdfRenderer` exposes no bbox → needs pixel scan. - **Prefetch/hinting** — koreader `readerhinting.lua:13-26` (pre-render ~3 pages async). @@ -257,7 +257,7 @@ silo renders fixed documents with Android's built-in `PdfRenderer` (no native Mu ### Concrete adoptable patterns -| Idea | Source location | Where in silo | Effort | License | A7 caveat | +| Idea | Source location | Where in prairie | Effort | License | A7 caveat | |---|---|---|---|---|---| | Memory-derived render cap + RGB_565 | koreader `doccache.lua:15,53`; Librera `MemoryUtils.java` | `renderPdfPageBitmap` `PdfReader.kt:284` | S | pattern only | critical — `memoryClass` on API24 can be 32–64MB | | LRU page-bitmap cache (recycle on evict) | doc-viewer `DecodeServiceBase.java:385-417` | cache around `PdfPage` (`:187`) | M | GPL pattern | window 1–3 pages on low-heap | @@ -272,19 +272,19 @@ silo renders fixed documents with Android's built-in `PdfRenderer` (no native Mu ### What NOT to borrow / risks -- Don't import MuPDF/pdfium or `com.artifex.*`/`org.ebookdroid.droids.mupdf.*` — silo deliberately uses `PdfRenderer`; MuPDF is a large native + GPL-coupling commitment (separate engine decision, not a borrow). +- Don't import MuPDF/pdfium or `com.artifex.*`/`org.ebookdroid.droids.mupdf.*` — prairie deliberately uses `PdfRenderer`; MuPDF is a large native + GPL-coupling commitment (separate engine decision, not a borrow). - Skip quadtree tiling for v1 — `PdfRenderer.Page.render` supports a clip `Rect` + `Matrix`, so region rendering is far simpler; revisit only for >4× zoom on huge pages. - Don't blindly allow 1–32× zoom — render *clipped regions* at high zoom, never a full-page bitmap scaled up. - Avoid giant fixed thread pools (doc-viewer thumbnail executor sized 256) — bound to 1–2 render threads. - The **2×/2000px hardcode (`PdfReader.kt:294`) is the single biggest A7 liability** — replace before adding zoom. -- koreader's k2pdfopt reflow is irrelevant — silo reflows via its separate reflow engine. +- koreader's k2pdfopt reflow is irrelevant — prairie reflows via its separate reflow engine. ## Audiobook Player ### Best-in-class reference(s) and why - **Voice** (GPL-3.0) — gold standard for an *audiobook-native* on-device player: per-book speed/gain/skip-silence, chapter cue points, sleep timer with fade + shake-to-reset + end-of-chapter, `MediaLibraryService`. Borrow its **playback-service shape, sleep-timer state machine, and per-book audio-processing model**. -- **lissen-android** (GPL-3.0) — gold standard for *server-backed multi-file timeline*: one MediaItem per chapter from clipped/concatenated file segments, centralized absolute↔(chapter,offset) math, a streaming `SimpleCache` **separate** from downloads, chapter-boundary-aware sync. Borrow its **timeline/MediaSource construction and dual-cache separation** — silo's biggest gap. +- **lissen-android** (GPL-3.0) — gold standard for *server-backed multi-file timeline*: one MediaItem per chapter from clipped/concatenated file segments, centralized absolute↔(chapter,offset) math, a streaming `SimpleCache` **separate** from downloads, chapter-boundary-aware sync. Borrow its **timeline/MediaSource construction and dual-cache separation** — prairie's biggest gap. - absorb / aradia / AudioAnchor / audiobookshelf-app — secondary UX/domain references. ### What they do well @@ -311,7 +311,7 @@ silo renders fixed documents with Android's built-in `PdfRenderer` (no native Mu ### Concrete adoptable patterns -| Idea | Source location | Where in silo | Effort | License | A7 caveat | +| Idea | Source location | Where in prairie | Effort | License | A7 caveat | |---|---|---|---|---|---| | One-MediaItem-per-chapter playlist from clipped/concatenated segments → true multi-file timeline | lissen `PlaybackService.kt:303`, `resolveChapterToFiles:255`, `LissenMediaSourceFactory.kt:62` | new `AudiobookMediaItemBuilder` → `ContinuumPlayerFactory`; VM stops collapsing to one `selectedFileId` (`:186`) | L | GPLv3 — reimplement | `ConcatenatingMediaSource2`/`ClippingMediaSource` fine on API24 | | Centralized absolute↔(chapter,offset) math + `CHAPTER_START_MS` | lissen `CalculateChapterIndexAndPosition.kt:20`, `PlaybackSynchronizationService.kt:175` | extend `AudiobookChapters.kt:36` with reverse-map + media-index helpers | S | GPLv3 pattern | pure Kotlin | @@ -323,27 +323,27 @@ silo renders fixed documents with Android's built-in `PdfRenderer` (no native Mu | Notification next/prev → interval; in-app → chapter skip | Voice `VoicePlayer.getAvailableCommands:109`, `LibrarySessionCallback:56` | `ContinuumPlaybackService` command/button config; **gate by media type** | M | GPLv3 pattern | Media3 commands API24 | | Keep audiobooks playing on swipe-away/off-screen | Voice (never stops on task removal); lissen `START_STICKY` | branch `onTaskRemoved:172` by media type; detach `MediaController` from Compose (`AudiobookPlayerScreen.kt:87`) into app-scoped holder + mini-player | M–L | own code | FG-service mediaPlayback type API34+; on A7 keep session alive | | Chapter-boundary-aware progress sync (tighten near boundaries; `timeListened` delta; finished-state) | lissen `PlaybackSynchronizationService.kt:69-136` | extend `AudiobookProgressSyncer` | S–M | GPLv3 pattern | n/a | -| Server-synced bookmarks (reuse ebook annotation pattern) | silo ebook `EbookReaderRepository.kt:30`; audiobookshelf-app | `AudiobookBookmarksStore:13` → repository + sync; note-edit in sheet | M | own code / server | n/a | +| Server-synced bookmarks (reuse ebook annotation pattern) | prairie ebook `EbookReaderRepository.kt:30`; audiobookshelf-app | `AudiobookBookmarksStore:13` → repository + sync; note-edit in sheet | M | own code / server | n/a | ### What NOT to borrow / risks - Don't collapse the audiobook player into a music/now-playing UX — keep the book metaphor (chapter list w/ per-chapter progress, whole-book scrub, chapter-aware skip). - **Don't let the streaming cache replace public downloads** — two stores: ephemeral `SimpleCache` (`externalCacheDir`, evictable) vs user-facing `DownloadStorage` (`Music/Prairie`, `DownloadStorage.kt:420,596`); `OfflineMediaResolver` stays the authoritative "downloaded?" check. -- Don't apply Voice's notification prev/next remap globally — silo's service is shared with video; gate by media type. +- Don't apply Voice's notification prev/next remap globally — prairie's service is shared with video; gate by media type. - Don't adopt lissen's per-chapter `ConcatenatingMediaSource2` for the video path; keep `ContinuumPlayerFactory` buffer/processor config branched by media type. -- Voice/lissen are XML/Hilt-based audiobook apps — borrow their playback-service shape and timeline math; reimplement in silo's Compose/KMP style rather than pasting. Seismic (shake) is a clean direct dependency. +- Voice/lissen are XML/Hilt-based audiobook apps — borrow their playback-service shape and timeline math; reimplement in prairie's Compose/KMP style rather than pasting. Seismic (shake) is a clean direct dependency. - A7: implement the background fix with an API-level branch (`START_STICKY` + persistent `MediaSession` on API24). ## Video Player -silo is **not greenfield** here — it already ships a `VideoPlaybackBackend` contract with both Media3 and MPV implementations, a route-capability matrix, a buffer-preset enum, a real `MediaCodecList` probe, and a Compose TV remote key mapper. The references are best used as a **maturity checklist** that exposes wiring gaps and two selection bugs. +prairie is **not greenfield** here — it already ships a `VideoPlaybackBackend` contract with both Media3 and MPV implementations, a route-capability matrix, a buffer-preset enum, a real `MediaCodecList` probe, and a Compose TV remote key mapper. The references are best used as a **maturity checklist** that exposes wiring gaps and two selection bugs. ### Best-in-class references & why | Ref | License | Why | |---|---|---| -| **jellyfin-androidtv** `playback/` | GPLv2 | Cleanest backend-behind-contract: `PlayerBackend` interface, `BackendService.switchBackend()` hot-swap. Target shape for silo's backend. | -| **findroid** `player/` | GPLv3 | Canonical `dev.jdtech.mpv:libmpv` integration — the *same* dep silo uses; `MPVPlayer extends BasePlayer`. | +| **jellyfin-androidtv** `playback/` | GPLv2 | Cleanest backend-behind-contract: `PlayerBackend` interface, `BackendService.switchBackend()` hot-swap. Target shape for prairie's backend. | +| **findroid** `player/` | GPLv3 | Canonical `dev.jdtech.mpv:libmpv` integration — the *same* dep prairie uses; `MPVPlayer extends BasePlayer`. | | **AFinity** `player/mpv/` | GPLv3 | Richest libass styling surface (`sub-ass-override`, `sub-border-style`, `sub-color`, `sub-font-size`). minSdk 35 — MPV not proven on A7. | | **Wholphin** `services/PlayerFactory.kt` | GPLv2 | Runtime backend choice as a factory + libass-aware ExoPlayer path (`AssRenderersFactory`) — libass *without* MPV. | | **jellyfin-android** `TrackSelectionHelper.kt` | GPLv2 | Delivery-method-aware track selection (EMBED/EXTERNAL/ENCODE). | @@ -357,7 +357,7 @@ silo is **not greenfield** here — it already ships a `VideoPlaybackBackend` co ### Prairie current state -silo is ahead on every axis except UI surfacing and two bugs. +prairie is ahead on every axis except UI surfacing and two bugs. - **Backend contract — DONE:** `backend/VideoPlaybackBackend.kt:12`; `Media3VideoPlaybackBackend` + `MpvVideoPlaybackBackend`; factory + `VideoPlaybackBackendSelector.kt:6` Auto policy (TRANSCODE→Media3, hard-container→MPV, styled-subs→MPV). - **Capability metadata — DONE but not surfaced:** `VideoBackendCapabilities.kt` carries `subtitleRendering`, `supportsHardContainers`, `displayName`. **Gap:** `video/VideoPlayerUiState.Ready:25` has no field for backend kind/displayName/subtitle-rendering → UI can't show "libass (MPV)" vs "Media3 text". Headline ask; wiring gap. - **MPV backend — DONE, real native libs:** `dev.jdtech.mpv:libmpv:1.0.0`; `libmpv.so` + ffmpeg packaged for **armeabi-v7a** (the 32-bit A7 ABI). `MpvPlayer.kt` complete `BasePlayer` (cache→buffering, libass via fontconfig, buffered position from `demuxer-cache-time`, auth headers). Auth headers pulled with `runBlocking` (`ContinuumPlayerFactory.kt:179`) — latent ANR. @@ -369,7 +369,7 @@ silo is ahead on every axis except UI surfacing and two bugs. ### Concrete adoptable patterns -| Idea | Source location | Where in silo | Effort | License | A7 caveat | +| Idea | Source location | Where in prairie | Effort | License | A7 caveat | |---|---|---|---|---|---| | Surface backendKind/displayName/subtitleRendering on player UI state | jellyfin-androidtv `PlayerState.kt:83` | add fields to `VideoPlayerUiState.Ready` (`:25`) from `backend.capabilities` | S | concept | none | | Fix unconditional text disable + apply `preferredTextLanguage` | jellyfin-android `TrackSelectionUtils.kt:13` | `TrackSelectionPresets.kt:69` & `:108` | S | GPLv2 — reimplement | none | @@ -380,11 +380,11 @@ silo is ahead on every axis except UI surfacing and two bugs. | Delivery-method-aware track switching | jellyfin-android `TrackSelectionHelper.kt:43,105,151` | `VideoTrackSelectionCoordinator` + `PlaybackSessionManager.changeAudio` | M | GPLv2 — pattern | none | | Explicit FF/REW + press-and-hold scrub | jellyfin-androidtv `CustomPlaybackOverlayFragment.java:453` | `TvPlayerRemoteKeyAction.kt:19` add media keys | S | GPLv2 — pattern | none | | Tighter resume (3s/15s) + server→client seek listener | jellyfin-androidtv `PlaybackController.java:59`, `PlaySessionSocketService.kt:37` | `PlaybackSessionManager.reportProgress` (`:71`) | S–M | GPLv2 — pattern | none | -| Move `runBlocking` auth fetch off player-build thread | (silo-internal) | `ContinuumPlayerFactory.kt:179` | S | n/a | avoids ANR on slower A7 | +| Move `runBlocking` auth fetch off player-build thread | (prairie-internal) | `ContinuumPlayerFactory.kt:179` | S | n/a | avoids ANR on slower A7 | ### What NOT to borrow / risks -- **Don't make MPV the default / sole path on TV/A7.** Both MPV references run minSdk 28/35 — neither validates libmpv on API24/armeabi-v7a (silo packages the `.so`, but "builds" ≠ "decodes reliably on a 2017 ARMv7 box"). Keep MPV opt-in/Auto-only (as `VideoPlaybackBackendSelector` already does); keep abiFilters tight (APK size). +- **Don't make MPV the default / sole path on TV/A7.** Both MPV references run minSdk 28/35 — neither validates libmpv on API24/armeabi-v7a (prairie packages the `.so`, but "builds" ≠ "decodes reliably on a 2017 ARMv7 box"). Keep MPV opt-in/Auto-only (as `VideoPlaybackBackendSelector` already does); keep abiFilters tight (APK size). - **Don't destabilize the Media3 path during the reading phase.** Highest-value, lowest-risk S items (text-disable fix, capability surfacing, FF/REW, off-thread auth) are all Media3-side — land those first; defer libass-on-Media3 (L) and MPV styling (M). - The Jellyfin clients (jellyfin-androidtv/android, Wholphin) are XML View-based; reimplement their ideas in Compose rather than pasting view code — engineering fit, not license. `dev.jdtech.mpv:libmpv` is already a binary dep. - Keep MPV's event-driven `STATE_BUFFERING` (push) rather than poll-only buffer; use `getBufferedPosition()` only for the scrub cushion. @@ -412,7 +412,7 @@ silo is ahead on every axis except UI surfacing and two bugs. ## Notifications (survey) -**Prairie current state.** REST-source-of-truth inbox + realtime accelerator: pure `applyEvent` fold (`NotificationsRepository.kt:58`); capped-backoff reconnect (`:227`); optimistic `markRead`/`markAllRead` with revert (`:169,177`); `reset()` on profile switch (`:212`). Realtime client mints ws-ticket, decodes frames (`NotificationsRealtimeClient.kt:65`). **silo is far ahead of every reference here** (streamyfin = expo-push/badge only; jellyfin clients = in-memory app alerts; Campfire = server config) — only correctness gaps: +**Prairie current state.** REST-source-of-truth inbox + realtime accelerator: pure `applyEvent` fold (`NotificationsRepository.kt:58`); capped-backoff reconnect (`:227`); optimistic `markRead`/`markAllRead` with revert (`:169,177`); `reset()` on profile switch (`:212`). Realtime client mints ws-ticket, decodes frames (`NotificationsRealtimeClient.kt:65`). **prairie is far ahead of every reference here** (streamyfin = expo-push/badge only; jellyfin clients = in-memory app alerts; Campfire = server config) — only correctness gaps: - Unsynchronized `_state` RMW — fold all mutations through `_state.update{}` (atomic pattern); derive rows/unread from `_state`. **S** — *branch-review bug.* - No terminal on persistent auth failure — `connectRealtime` loops forever; `Closed("ticket_error_401")` folds as no-op. Stop/long-backoff on 401/403; expose a disconnected state. **M** — *branch-review bug.* - Backoff not reset on clean close — reset on successful *connect* (hello/subscribed), not on traffic. **S** — *branch-review bug.* @@ -429,7 +429,7 @@ silo is ahead on every axis except UI surfacing and two bugs. ## Admin (survey) -**Prairie current state.** `AdminRepository.kt` stateless pass-through: stats, user CRUD, session list + control, app/audit logs, library scan. ViewModels: `AdminUsersViewModel` (generation-gated, `:34`), `AdminUserEditViewModel` (`:53`), `AdminStatsViewModel`. **No reference app covers admin** — silo is the only one with real CRUD + session control + logs. Correctness only: +**Prairie current state.** `AdminRepository.kt` stateless pass-through: stats, user CRUD, session list + control, app/audit logs, library scan. ViewModels: `AdminUsersViewModel` (generation-gated, `:34`), `AdminUserEditViewModel` (`:53`), `AdminStatsViewModel`. **No reference app covers admin** — prairie is the only one with real CRUD + session control + logs. Correctness only: - Cleared library-ids field revokes all access — `update` always sends `libraryIds = parseLibraryIds(text)` (`:137`); blank → `emptyList()` (`AdminUserForm.kt:48`), *sent* (not omitted) → server revokes all. Send `null` when blank, or distinguish cleared vs empty. **S** — *branch-review bug.* (Quota fields are safe — `parseQuota` returns null.) - `deleteUser` — current code removes from list **only on success** (`:59`), no optimistic resurrect path; the review's "resurrect" premise appears stale for this revision. **No change unless an optimistic variant returns** — confirm. - `AdminUserEditViewModel.load` idempotent guard (`loaded` flag, `:60`) can't switch targets within one instance — correct only if always freshly scoped per navigation; else add id-aware reload. **S.** @@ -440,7 +440,7 @@ silo is ahead on every axis except UI surfacing and two bugs. ## Provenance Ledger Lineage breadcrumbs (maintenance aid, not a license requirement). "Mode" = whether the -source's architecture is a clean fit (copy) or clashes with silo's stack (reimplement). +source's architecture is a clean fit (copy) or clashes with prairie's stack (reimplement). Update as items land. | Idea borrowed | Source app | Source license | Mode | diff --git a/docs/superpowers/specs/2026-06-17-android-tv-detail-parity-design.md b/docs/superpowers/specs/2026-06-17-android-tv-detail-parity-design.md index b0d163d3e..9baaae270 100644 --- a/docs/superpowers/specs/2026-06-17-android-tv-detail-parity-design.md +++ b/docs/superpowers/specs/2026-06-17-android-tv-detail-parity-design.md @@ -14,7 +14,7 @@ ## 0. Apple source map (the contract) -Branch `feature/playback-ux-redesign` at `/Users/jimcole/projects/silo/prairie-apple`, all under `iosApp/iosApp/tvOS/Screens/Detail/`: +Branch `feature/playback-ux-redesign` at `/Users/jimcole/projects/prairie/prairie-apple`, all under `iosApp/iosApp/tvOS/Screens/Detail/`: - `TVMovieDetailView.swift` — movie/episode page composition (action row, More menu, body order, focus). - `TVSeriesDetailView.swift`, `TVSeasonDetailView.swift` — series/season page composition (read these for season-chip + episode-rail placement and the next-up-driven selector). - `TVDetailHero.swift` — hero (heroHeight 980, scrim stops, editorial column, title treatments, eyebrow, source row, facts row, starring overlay, `TVHeroMetadata`). diff --git a/docs/superpowers/specs/2026-06-17-playback-behavior-migration-design.md b/docs/superpowers/specs/2026-06-17-playback-behavior-migration-design.md index 17d93d9e9..3f13211ce 100644 --- a/docs/superpowers/specs/2026-06-17-playback-behavior-migration-design.md +++ b/docs/superpowers/specs/2026-06-17-playback-behavior-migration-design.md @@ -28,7 +28,7 @@ three planned subsystem specs: - Pure, reusable logic lives in `shared`/`android-shared` so `androidTvApp` can adopt it later and so it is unit-testable without Android dependencies. - UI (prompts, overlays) stays per-app. -- Match silo's existing patterns (`RoomSyncController`, `WatchTogetherRealtimeClient`, +- Match prairie's existing patterns (`RoomSyncController`, `WatchTogetherRealtimeClient`, `PlayerViewModel`) rather than copying continuum verbatim. - Reuse methods `PlayerViewModel` already exposes; do not duplicate playback control. diff --git a/docs/superpowers/specs/2026-07-27-android-media-buffer-sizing-design.md b/docs/superpowers/specs/2026-07-27-android-media-buffer-sizing-design.md new file mode 100644 index 000000000..3b8dec132 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-android-media-buffer-sizing-design.md @@ -0,0 +1,56 @@ +# Android Media-Aware Buffer Sizing Design + +## Goal + +Prevent fast network delivery from inflating Android phone and TV byte-buffer targets while preserving existing playback time thresholds, device byte floors and caps, and HTTP Range resume/retry behavior. + +The change is limited to the shared `android-shared` `PrairieLoadControl`. It does not alter Prairie Server, Apple clients, production proxy configuration, or the progressive data-source retry path introduced for issue #80. + +## Bitrate Selection + +For each selected audio or video track: + +1. Use a positive `Format.averageBitrate`. +2. If average bitrate is absent or invalid, use a positive `Format.peakBitrate`. +3. Do not treat `Format.bitrate` as an independent input because Media3 defines it as peak bitrate when available, otherwise average bitrate. + +Sum the selected tracks' known media bitrates. If any selected track supplies valid media metadata, that media sum is the sizing estimate and all `ExoTrackSelection.latestBitrateEstimate` values are ignored. This prevents delivery capacity on a fast LAN from being mistaken for encoded media consumption. + +Only when no selected track has valid average or peak metadata may the largest positive `latestBitrateEstimate` be used as a last-resort estimate. The maximum is used rather than a sum because adaptive selections commonly share one bandwidth estimate. If neither metadata nor a positive network estimate exists, retain `DefaultLoadControl`'s target-buffer calculation. + +## Buffer Calculation + +The selected estimate continues through the existing calculation: + +- enough bytes for `minBufferMs`; +- the existing 15 percent container/protocol overhead; +- the existing 16 MiB minimum target; +- the existing device-specific maximum target. + +Startup, rebuffer, and back-buffer time thresholds are unchanged. + +## Adaptation and Reset + +Media3's `LoadControl` boundary exposes buffered duration and allocator bytes, but not a reliable encoded-byte consumption rate. Allocator growth is retained buffer, not consumption, and `latestBitrateEstimate` represents delivery capacity. Using either as an observed-consumption proxy would recreate the bug under a different name. + +This implementation therefore remains deliberately stateless. Track or session changes invoke target calculation with the new selections, naturally discarding the previous estimate. Upward adaptation and decay are deferred until a reliable encoded-consumption signal is available at this boundary. + +## Verification + +Focused tests cover: + +- average bitrate taking precedence over peak bitrate; +- peak bitrate when average is absent; +- invalid or absent metadata; +- multi-track media-rate summation; +- network estimate used only when all media metadata is absent; +- network capacity not inflating a metadata-derived target; +- unchanged byte floors, caps, and unknown-bitrate fallback. + +The existing progressive Range-resume integration test protects issue #80 behavior. Full shared tests and phone/TV debug and release compilation verify the common load-control wiring. A local short-timeout canary may be used to exercise retry behavior without changing production proxy settings; inability to produce genuine socket backpressure with a small local fixture will be recorded as a validation limitation rather than replaced with a misleading proxy. + +## Alternatives Rejected + +- Metadata-only sizing with no network fallback conflicts with the approved last-resort behavior for metadata-poor media. +- Using allocator growth or bandwidth estimates as observed consumption is not a valid encoded-consumption measurement. +- Adding transport instrumentation or duplicating private `DefaultLoadControl` loading logic would be disproportionate and risks changing issue #80 behavior. diff --git a/docs/superpowers/specs/2026-07-27-android-tv-navigation-remediation-design.md b/docs/superpowers/specs/2026-07-27-android-tv-navigation-remediation-design.md new file mode 100644 index 000000000..b69f2e7f6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-android-tv-navigation-remediation-design.md @@ -0,0 +1,214 @@ +# Android TV Navigation Remediation Design + +## Purpose + +Fix two Android TV regressions reported against v1.0.0 and reproduced on the +current client against `lib.strm.cafe`: + +1. The For You filter band cannot transfer D-pad focus into recommendation + rows. +2. Home navigation is sluggish during the first traversal after process start, + then becomes responsive after the same content is warm. + +The change is Android TV focused. Shared startup hydration may change where it +directly removes duplicate Android phone/TV work, but server APIs, server +configuration, content ordering, recommendation generation, and playback are +out of scope. + +## Verified Causes + +### For You focus + +`TvRecommendationsScreen` initially focuses the Watchlist pill but does not +provide a Down target or directional handler for any filter pill. Its +`TvMediaRow` instances also receive neither a row-container focus requester nor +a first-card focus requester. Compose's geometric search therefore leaves focus +on Watchlist, while Watchlist and Favorites work because their catalog grids +provide a different focus topology. + +### Cold Home navigation + +Several independent eager paths overlap after process start: + +- `warmAuthenticatedStartup` fetches the aggregate Home response and then + unconditionally fetches every section again with unbounded concurrency. +- `HomeViewModel` independently fetches the aggregate Home response while the + startup warmup is running. +- `TvSkylineSectionFeed` starts hero artwork and full-detail requests for as + many as sixteen cards on page entry. +- Raw focus movement starts neighbor artwork and network-first detail requests + before the existing 150 ms rested-focus decision. + +Servers with more libraries and rows amplify the first two costs, but server +size is not itself an error. The client must remain responsive for a +production-sized Home response. + +Archived PR 108 commits `49a70045` and `65c4b316` document earlier intent to +remove the page-entry fan-out and hydrate only unresolved Home sections with a +four-request bound. They are historical references, not patches to apply +blindly: current main has newer Home response handling and must retain it. + +## Design + +### 1. Explicit For You focus bridge + +`TvRecommendationsScreen` will own stable focus requesters for: + +- the filter pill that should receive Up from recommendation content; +- the first nonempty recommendation row container; and +- the first card in that row. + +Every filter pill routes Down according to the visible content: + +- For You: request the first row container, wait one frame for the row + `focusRestorer` boundary, then request its first card. +- Watchlist and Favorites: preserve their existing catalog-grid behavior. +- Loading, error, and empty states: do not target an absent row; their existing + actionable control remains reachable. + +The first For You row routes Up back to the selected filter pill. Initial entry +continues to focus Watchlist, preserving current product behavior. + +The handoff will reuse the established `TvMediaRow` requester contract rather +than introducing a second focus-navigation architecture. + +### 2. Deliberate Home-to-menu focus boundary + +Rapid or held Up input must traverse Home rows one at a time and stop on the +first content row. Reaching that row as part of the same repeated-key sequence +must not immediately move focus into the top menu. A new Up press after the +remote key has been released may enter the selected top-menu item. + +The Skyline row band will continue to own off-screen row relocation. It will +serialize that relocation so overlapping key-repeat events cannot start +competing scroll jobs or consume more row transitions than completed focus +moves. The shell remains the only owner of the final content-to-menu handoff. + +### 3. Unify For You saved-list presentation + +Watchlist and Favorites chosen from the For You top-menu selector will open the +existing `TvRecommendationsScreen` with the matching saved-list pill selected. +They will therefore use the same filter band, inline grid, spacing, focus +behavior, and Back destination as choosing those pills after entering For You. + +Watchlist and Favorites chosen from the profile menu remain standalone utility +pages. This preserves their established account-navigation role and avoids +turning every saved-list deep link into a For You route. No repository, server, +or personal-data behavior changes; both presentations continue to use the +existing `WatchlistViewModel` and `FavoritesViewModel`. + +### 4. Resolve Home once, hydrate only missing sections + +Extract a small, platform-neutral Home hydration operation that: + +- accepts aggregate `ResolvedSection` values; +- preserves sections whose items are already inline; +- fetches only sections that are empty while reporting a nonzero total; +- accepts either nested response items or top-level response items; +- limits fallback requests to four concurrently; +- reports whether the snapshot was fully resolved so a partial result cannot + replace a good cache. + +`HomeViewModel` and startup warmup will share this operation. Startup warmup +will no longer refetch every inline section. + +The activity warmup remains best-effort and non-blocking. This change does not +make splash dismissal wait for Home. + +### 5. Remove page-entry detail fan-out + +`TvSkylineSectionFeed` will not eagerly fetch full detail or hero-sized artwork +for the first sixteen cards merely because rows entered composition. Startup +artwork warmup already has a bounded, paint-order budget; the Skyline will seed +the first marquee from aggregate section data and enrich around actual user +focus. + +Neighbor enrichment remains speculative but will be driven by the rested +focused identity rather than every intermediate D-pad position. It will: + +- operate on a small neighbor window; +- preserve request deduplication for the page lifetime; +- use cached item detail before network; +- cap network detail concurrency; +- cancel obsolete work when rested focus changes. + +Opening an item-detail screen keeps its existing network-first freshness +semantics. Cache-first behavior applies only to speculative marquee enrichment. + +### 6. Cache and dispatcher boundaries + +Room remains the profile/server-scoped source for offline Home and item-detail +snapshots. The fix will not alter cache schema or migration state. + +Home JSON encoding and Room access continue on the existing IO-owned startup +scope or repository suspending boundary. If tests show serialization executing +on the main dispatcher, the repository will explicitly move serialization to a +background dispatcher; otherwise no dispatcher abstraction will be added. + +## Error and Lifecycle Behavior + +- Failed speculative image/detail requests remain non-fatal and do not block + focus. +- A failed fallback section fetch leaves the prior complete Home cache intact. +- Switching server or profile continues to select the corresponding scoped + Room data. +- Leaving the Home composition cancels its speculative jobs. +- A process restart may refresh content, but it must not re-download cached + detail solely for prefetch. +- No tokens, origins, diagnostics, or production settings change. + +## Verification + +Behavioral tests will cover: + +- Down from each For You filter pill, Up return, repeated movement, and + loading/error/empty states; +- rapid and held Up sequences stopping on Home's first content row, followed + by a fresh Up press entering the selected top-menu item; +- For You selector Watchlist/Favorites opening the same inline presentation + and selected pill as in-page selection, while profile-menu routes remain + standalone; +- inline Home sections causing zero per-section fallback requests; +- missing sections being hydrated correctly with at most four concurrent + requests; +- partial hydration preserving cache safety; +- Skyline page entry producing no full-detail burst; +- rapid focus movement starting work only for the rested identity; +- cached detail avoiding network and bounded fallback when cache misses. + +Verification will include focused shared and Android TV unit tests, Android TV +debug/release compilation, and a controlled Shield run against +`lib.strm.cafe`. The device gate will compare cold-process and immediate warm +traversals using frame-jank percentiles and sanitized request counts. Success +requires: + +- For You Down enters its first visible recommendation card reliably; +- no page-entry sixteen-detail burst; +- no per-section N+1 when aggregate sections are inline; +- materially lower cold first-traversal request count and jank without + worsening warm traversal; +- no crash, ANR, authentication change, or server mutation. + +## Alternatives Rejected + +- **Server-side global row caps:** reduces content and masks client-side + duplicate work. +- **Longer splash or input suppression:** hides latency instead of removing it. +- **Blind cherry-pick of archived commits:** risks discarding newer response + compatibility and focus-restoration behavior. +- **Disabling all prefetch:** avoids the burst but makes every settled focus pay + full network and image latency. +- **Timeout or animation tuning:** does not address the measured cold request + fan-out. +- **Making all Watchlist/Favorites routes inline:** would change profile-menu + and deep-link semantics to solve a mismatch limited to the For You selector. +- **Debouncing all Up input:** would make ordinary row traversal feel laggy; + only the asynchronous row relocation and content/menu boundary need + sequencing. + +## Scope Boundaries + +This remediation does not change recommendation ranking, Home row composition, +server endpoints, production configuration, phone UI navigation, playback, +database schema, or authentication. It does not attempt a general Compose focus +framework or a complete image-loading redesign. diff --git a/docs/superpowers/specs/2026-07-27-pr108-slice-f-watch-together-design.md b/docs/superpowers/specs/2026-07-27-pr108-slice-f-watch-together-design.md index e6cc24e8c..d262a90b8 100644 --- a/docs/superpowers/specs/2026-07-27-pr108-slice-f-watch-together-design.md +++ b/docs/superpowers/specs/2026-07-27-pr108-slice-f-watch-together-design.md @@ -93,7 +93,7 @@ already-applied command. ## Credentials -The Silo auth plugin already attaches same-origin `Authorization`, +The Prairie auth plugin already attaches same-origin `Authorization`, `X-Profile-Id`, and `X-Profile-Token` headers to websocket handshakes. Slice F removes the redundant access JWT from the websocket URL and verifies the header/query boundary with a real handshake test. diff --git a/docs/superpowers/specs/2026-07-27-watch-together-user-menu-entry-design.md b/docs/superpowers/specs/2026-07-27-watch-together-user-menu-entry-design.md new file mode 100644 index 000000000..a4ecd289c --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-watch-together-user-menu-entry-design.md @@ -0,0 +1,214 @@ +# Watch Together User-Menu Entry — Design + +**Date:** 2026-07-27 + +**Status:** Approved design, awaiting written-spec review + +**Clients:** Android phone and Android TV + +## Goal + +Expose Watch Together from each client's user/profile menu without requiring a +title first. The new entry is a lightweight launch surface for hosting an empty +vote room, joining by code, or resuming the room already owned by the current +app session. The existing title-detail entry remains available and continues to +host with that title preselected. + +This product decision supersedes the older “hidden from user menus” direction +for Watch Together only. It does not expose rich administration or change any +other menu policy. + +## User experience + +Both profile menus add a **Watch Together** row in their content/action group. +When **Requests** is present, Requests remains first and Watch Together appears +immediately after it. When Requests is absent, Watch Together remains in the +same content/action group immediately before the divider that precedes settings +and account actions. + +Selecting the row closes the profile menu and opens a transient, dedicated +Watch Together entry surface: + +1. **Resume current room** appears first only when the current authenticated + app session owns a non-terminal room snapshot. +2. **Host a room** creates a room in vote mode with no selected content, then + opens the existing lobby. +3. **Join by code** opens the existing code-entry flow. A successful join uses + the existing room destination decision: a room with playable selected + content goes to the synchronized player; an unselected room goes to the + lobby. The existing host-alone rule may keep a host in the lobby to share + the invite. + +The owner of a top-level empty vote room is a full room participant. The owner +may suggest titles, vote, apply the existing host override to any suggestion in +the room (that is, any room-owned suggestion), and close the room for everyone. +These are the existing voting and host-authority capabilities exposed by the +current lobby and repository; the menu entry adds no new role, permission, +protocol message, or server behavior. + +On phone, the dedicated surface follows the existing modal-sheet idiom. On TV, +it follows the existing focused popup/dialog idiom. It is not a new persistent +Watch Together home or a replacement for the lobby. When Resume is available, +it receives initial TV focus; otherwise Host receives initial focus. Back, +dismissal, and busy-state input blocking match the existing entry surfaces. + +Resuming uses the same destination decision as joining. It does not create, +join, or reconnect through a second path; the selected destination adopts the +existing room through `RoomSession`. + +## Existing title-detail behavior + +Movie, episode, and existing playable-series detail affordances remain intact. +Their **Watch Together** action continues to open the title-bound entry surface: + +- **Host a room** creates the room and sets the current title/file selection. +- **Host a vote room** and **Join by code** retain their current behavior. +- Existing feature-policy and media-type gates remain unchanged. + +The new menu entry never invents a content ID, opens a title picker, or changes +the meaning of the detail action. + +## Architecture and state ownership + +This is an additional presentation entry point over the existing Watch +Together system: + +- `WatchTogetherRepository` remains the only owner of REST room operations, + room credentials, auth-scope validation, room snapshots, websocket state, + voting, suggestions, and server errors. +- `RoomSession` remains the process-scoped connection owner and the only path + for adopting, replacing, leaving, or closing a room connection. +- The existing phone and TV lobby, player, websocket, voting, and routing + surfaces remain authoritative. +- The entry controllers reuse the existing create/join state machines. The + title-free host action is explicitly + `CreateRoomRequest(selectionMode = RoomSelectionMode.Vote.wire)` and must not + call `setSelection`. +- Phone and TV may keep platform-specific composables and navigation types, but + they must derive equivalent entry actions and destination decisions from the + same room snapshot semantics. No second repository, room cache, websocket + client, or parallel “menu room” state is introduced. + +“Current room” means the valid, non-terminal room represented by the existing +process-scoped repository/session state for the current server and profile. +Identity transitions and room termination already clear that state. This +feature does not persist room credentials, discover rooms after process death, +or add a server-side “my active room” lookup. A successful create or join may +replace the current room through the existing generation/lease and +`RoomSession` replacement rules. + +## Host ownership and continuity + +Current server host semantics remain authoritative. The room creator remains +the host; the clients do not automatically transfer ownership or elect a new +host. + +Normal navigation and backgrounding preserve the process-scoped room while the +app process and authenticated profile remain alive. A temporary transport loss +uses the existing server grace period and repository reconnect behavior; a +successful reconnect within that behavior resumes the same room and host +authority. + +Logout, profile or server switch, and process death clear the client's local, +profile-scoped room state. After the host disconnects, the server may close the +room when its existing host-disconnect timeout expires. The clients do not +extend that timeout, transfer the host role, or reclaim a room after the server +has closed it. + +Accordingly, **Resume current room** is intentionally limited to the same +running app process, server, and authenticated profile. It is not account-level +room recovery. + +## Routing and lifecycle + +- Empty vote-room creation always routes to the existing lobby with its + `roomId`; the lobby establishes the existing session adoption and websocket + flow. +- Join and Resume route to the existing player only when the shared destination + rules say the snapshot is ready for playback; otherwise they route to the + existing lobby. +- Repeated taps while an operation is busy are ignored. The entry surface + cannot be dismissed while its create/join operation is in flight, matching + current behavior. +- Opening or dismissing the entry surface does not reset a current room. +- TV closes the profile dropdown before showing the popup so the dropdown and + popup never compete for D-pad focus. Dismissing the popup returns through the + shell's established focus-restoration path. +- Phone and TV consume one-shot navigation results before navigating, preventing + recomposition from launching the lobby or player twice. + +## Authentication, transport, and errors + +The menu action is available only in the authenticated profile shell and uses +the current server/profile scope. All calls continue through the repository and +existing network clients, preserving auth-scope transition barriers, cleartext +consent, room-token handling, reconnect behavior, and credential redaction. +Room credentials must not be copied into UI state, logs, or new route +parameters. + +Create and join failures remain on the entry surface using the existing +user-facing error mapping. A failed operation does not navigate or discard a +previously active room. Lobby/player websocket and terminal-room errors remain +owned by those existing surfaces. No new fallback transport or retry policy is +added. + +## Test strategy + +Focused automated coverage must establish: + +- **Phone menu visibility:** Watch Together is present in the authenticated + profile menu, invokes the entry surface, appears immediately after Requests + when Requests is present (or immediately before the settings/account divider + otherwise), and does not disturb account actions. +- **TV menu visibility and focus:** the row is present in the profile dropdown; + it follows Requests when Requests is present and otherwise ends the same + content/action group; opening it closes the dropdown; Resume is initially + focused when present, otherwise Host is; Back restores focus without leaking + focus behind the popup. +- **Empty host flow:** both clients issue one vote-mode create request, never + call `setSelection`, and navigate to the existing lobby with the returned + room ID. +- **Owner authority regression:** in that lobby, the owner remains able to + suggest, vote, exercise the existing host override on any room-owned + suggestion, and close the room for everyone, without a new protocol or + permission path. +- **Join routing:** code normalization/validation and errors remain intact; + selected rooms route to the synchronized player and unselected rooms route to + the lobby on both clients. +- **Resume routing:** Resume appears only for valid current-session state, + routes through existing snapshot rules without a create/join call, and + disappears after room termination or an identity change. +- **Host continuity:** navigation/backgrounding retains the same process-scoped + room; a temporary disconnect follows existing grace/reconnect behavior; and + logout, profile/server switch, or process death removes Resume and + local room state without transferring host ownership. +- **Parity:** the phone and TV entry surfaces expose the same action set and + state-dependent behavior, with platform-appropriate presentation. +- **Regression:** title-detail Watch Together remains visible under its current + policy/media gates and still hosts with the selected content/file rather than + creating an empty room. + +Existing repository, `RoomSession`, lobby, websocket, voting, player, auth, +cleartext-consent, and replacement-race tests remain part of the verification +gate. Device checks should cover phone touch interaction and TV D-pad +focus/back behavior; they do not replace the automated routing tests. + +## Out of scope + +- A title picker before room creation. +- A new full Watch Together home, room browser, or room history. +- Automatic host transfer, ownership election, or original-host reclaim. +- Cross-process room restoration or a new server endpoint. +- Changes to room protocol, websocket ownership, voting rules, player sync, + cleartext policy, or authentication. +- Removal or redesign of the title-detail Watch Together action. +- Changes to non-Android clients. + +## Acceptance criteria + +The feature is complete when an authenticated phone or TV user can open Watch +Together from the profile menu, host an empty vote room into the existing +lobby, join through the existing code flow, and resume current in-process room +state when available; both clients behave equivalently, TV focus is +deterministic, title-detail hosting still preselects its title, and no parallel +room/session architecture or credential exposure is introduced. diff --git a/docs/superpowers/specs/2026-07-28-android-active-header-focus-editorial-hero-design.md b/docs/superpowers/specs/2026-07-28-android-active-header-focus-editorial-hero-design.md new file mode 100644 index 000000000..ce2915649 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-android-active-header-focus-editorial-hero-design.md @@ -0,0 +1,207 @@ +# Android Active Header Focus and Editorial Hero Design + +**Date:** 2026-07-28 +**Status:** Approved for implementation planning +**Scope:** Android TV header focus; Android phone and TV browsing heroes + +## Context + +External testing of the TV navigation work in PR #126 confirmed that cold +navigation and For You behavior improved. It also exposed two related +presentation defects: + +1. Pressing Up from the first content row can focus Search instead of the + currently active top-menu destination. Pressing Back from the same page + correctly focuses the active destination. +2. TV browsing heroes prioritize technical stream badges such as resolution, + HDR, and audio format ahead of editorial information. Phone library heroes + already avoid those technical fields, but their generic type/year/rating + chips omit runtime, content classification, and genre. + +Issue #78 asks for richer title metadata, but its current wording refers to the +player. This change applies the approved behavior to Android phone and TV +browsing heroes only and does not change either player's or item-detail +surface. + +## Goals + +- A fresh Up press from the first content row focuses the active top-menu + destination on Home, each library section, For You, and Calendar. +- Search receives this focus only when Search is the active route. +- Preserve the existing held-Up boundary: a held key stops on the first + content row and requires a fresh Up press before entering the menu. +- Phone and TV browsing heroes describe the title using consistent editorial + metadata instead of delivery characteristics or generic media-type labels. +- Keep TV focus behavior inside the existing shared TV shell. +- Keep hero presentation inside the existing TV marquee model and phone + featured-carousel metadata helper. + +## Non-goals + +- No server, API, database, or payload changes. +- No player-overlay, playback-settings, or item-detail redesign. +- No phone Home hero: Android phone Home intentionally renders rows without a + billboard, so phone changes apply only to the existing Library Recommended + featured carousel. +- No changes to stream selection, transcoding, subtitle behavior, or technical + metadata availability outside the browsing hero. +- No new user preference or display toggle. +- No Apple-client changes. + +## Focus Behavior + +The shell remains the single owner of content-to-menu focus transitions. When +the active content feed reports that a fresh Up press has reached its first +row, the shell requests the menu destination derived from the current route: + +- Home → Home +- Movies, Series, Music, or Audiobooks → the matching library-type pill +- For You → For You +- Calendar → Calendar +- Search → Search + +The request must target the active destination explicitly and complete through +the existing menu focus-request mechanism. It must not depend on Compose +geometric focus search or on the physical proximity of Search to the content +card. The same mapping is used by Back-to-menu behavior so the two entry paths +cannot drift. + +Repeated Up events at the first content row remain consumed. Off-screen +previous-row relocation and ordinary row-to-row Up movement are unchanged. +Panel preview, profile menu, Left/Right menu traversal, and Down-to-content +behavior are unchanged. + +## Shared Browsing Hero Metadata + +The TV browsing marquee stops rendering resolution, HDR, and audio-format +badges. Technical overlay data remains in the model for other consumers but is +not converted into hero badges. The phone featured carousel continues to avoid +technical delivery data. + +Both Android clients use the following ordered editorial fields when present: + +### Movies and other non-episode titles + +1. Release year +2. Runtime +3. IMDb rating +4. Primary genre + +Content classification, such as PG-13, remains as the only badge adjacent to +that ordered metadata line. + +### Episodes + +1. Season and episode token, such as `S2 E7` +2. Episode name +3. Runtime +4. Air date when available from existing enrichment +5. Rating when present + +Content classification, such as TV-MA, remains as the only badge adjacent to +that ordered metadata line. + +The series name remains the episode hero title, with the episode name in the +metadata line. Missing values are omitted without placeholders or redundant +separators. Existing synopsis, cast enrichment, artwork, cache-first loading, +and crossfade behavior remain unchanged. + +The implementation may keep air date and cast on the existing quieter detail +line if the current payload/enrichment boundary does not expose air date early +enough for the primary metadata line. It must not add another detail request or +delay first paint to rearrange those fields. + +### TV presentation + +TV retains its existing badge-plus-metadata-line layout. Content +classification is its only hero badge; the remaining fields form the ordered +single metadata line. The existing episode series title, episode-name +placement, synopsis, and quieter air-date/cast enrichment remain unchanged. + +### Phone presentation + +Phone applies the editorial fields to the existing featured carousel used on +Library Recommended pages: + +- Remove the generic `Movie` or `Episode` type chip. +- Preserve the existing series eyebrow and title treatment, so an episode + continues to show the series name and episode name without duplication. +- Present the ordered metadata as compact chips using the existing chip visual + style. +- Allow chips to wrap onto a second line on narrow phones rather than clipping + or forcing horizontal scrolling. +- Do not add a hero to phone Home or change item-card overlays. + +Phone does not perform detail enrichment in the carousel. Air date remains +absent when it is not carried by the existing section payload; no new request +is introduced to obtain it. + +## Data Flow and Boundaries + +- `TvMainShell` derives the active root destination from the current route. +- `TvShellFocusState` carries the explicit menu-focus request. +- `TvTopMenuBar` resolves that destination to its existing `FocusRequester`. +- `TvSkylineSectionFeed` retains ownership of row traversal and the held-Up + boundary, but does not choose a menu target. +- `TvMarqueeContent.from` converts the existing `SectionItem` payload into + ordered editorial metadata. +- Existing detail enrichment may continue to add air-date/cast information + without blocking or re-fetching on focus. +- `FeaturedCarousel.metadataChips` converts the same existing `SectionItem` + fields into ordered phone chips. +- The phone carousel layout owns responsive wrapping without changing its + paging, play, More Info, or backdrop behavior. + +No parallel focus coordinator, marquee data source, or phone detail fetch is +introduced. + +## Error and Edge Handling + +- If the active route has no top-menu destination, preserve its existing + route-specific behavior rather than silently selecting Home. +- If a requested library pill is temporarily absent, use the existing safe + requester fallback and do not crash. +- Invalid, non-finite, zero, negative, blank, or unavailable metadata is + omitted. +- Valid ratings and runtimes keep the existing formatting and rounding rules. +- Removing technical badges must not create an empty visual row; the row is + omitted when no editorial badge or metadata value exists. +- Phone wrapping is bounded to two lines of metadata chips; it must not cover + the carousel actions or change the carousel's page height. + +## Verification + +Focused tests should cover: + +- route-to-menu target mapping for every root destination and Search; +- the held-Up first-row boundary remains unchanged; +- TV movie metadata ordering and omission of resolution/HDR/audio; +- TV episode metadata ordering, series/episode naming, runtime, rating, and + content-classification handling; +- phone movie and episode chip ordering, removal of the generic type chip, + runtime/content-classification inclusion, and no technical delivery fields; +- absent, zero, negative, NaN, or infinite ratings and runtimes without empty + chips or dangling separators. + +Regression verification should include the complete Android phone and TV unit +suites, supply-chain checks, and both minified release assemblies. A TV +emulator or external-device smoke should verify: + +- Up from the first row lands on the active pill across at least Home, a + library section, For You, and Calendar; +- Search is selected only on the Search route; +- held Up stops at the first content row; +- representative movie and episode heroes contain editorial metadata and no + resolution/HDR/audio badges. + +A phone emulator smoke should verify that representative movie and episode +featured-carousel pages show the approved chips, wrap without overlapping +actions on a narrow viewport, and retain Play, More Info, paging, and artwork. + +## Rollout + +Implement this as a focused follow-up on PR #126 while it remains open. The +shared hydration/navigation-performance commits already in PR #126 are its +accepted baseline and remain unchanged; this follow-up does not need to split +or reclassify them. Update both tester APKs after automated verification. Do +not merge or deploy as part of implementation. diff --git a/docs/superpowers/specs/2026-07-28-android-phone-library-chrome-inset-design.md b/docs/superpowers/specs/2026-07-28-android-phone-library-chrome-inset-design.md new file mode 100644 index 000000000..0182f812c --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-android-phone-library-chrome-inset-design.md @@ -0,0 +1,95 @@ +# Android Phone Library Chrome Inset Design + +Date: 2026-07-28 + +## Goal + +Match the current iOS Libraries tab behavior on Android phone: the library +selector, actions, and Recommended / Browse / Collections tabs remain fixed at +the top, while scrollable library content occupies only the space below that +chrome. Library rows, posters, and hero content must not scroll visibly behind +the menu controls. + +This change is phone-only. It does not alter Android TV, standalone Browse or +Collections routes, Home, server APIs, navigation semantics, or the profile +menu's actions. + +## Current Behavior and Root Cause + +`LibrariesScreen` draws its tab content full-screen and then draws +`LibrariesFloatingChrome` afterward in the same root `Box`. The chrome uses a +partially transparent gradient. Recommended and Collections reserve initial +top runway inside their scroll containers, but that runway scrolls away; later +content therefore remains visible beneath the selector and tab controls. +Browse already uses fixed outer padding and does not exhibit the same underlap. + +The iOS implementation uses a top `safeAreaInset`, which reduces the space +offered to every library tab's scroll view. Its content therefore starts below +the shared chrome and cannot pass behind it. + +## Considered Approaches + +1. **Reserved top chrome slot — selected.** Keep the existing custom Android + chrome and full-screen backdrop, but place the chrome and tab-content + viewport in a vertical layout. The chrome consumes its measured height and + the content viewport receives the remaining height. This directly matches + the layout semantics of iOS `safeAreaInset` without replacing existing + controls. +2. **Stronger translucent scrim.** Leave content underneath but obscure it more + once scrolling starts. This reduces visual noise but does not fix the + reported behavior and remains inconsistent across tabs. +3. **Material `Scaffold.topBar`.** This also reserves layout space, but would + introduce a larger structural and visual migration for a custom chrome that + already works. It is unnecessary for this focused correction. + +## Layout Design + +The root retains its full-screen background and optional Recommended hero +backdrop. Above that background, a vertical foreground layout owns: + +1. `LibrariesFloatingChrome`, including the status-bar inset, library selector, + action buttons, profile popup anchor, subtab selector, and its bottom space. +2. A clipped, weighted content viewport containing loading, error, empty, + Recommended, Browse, or Collections content. + +Because the chrome participates in measurement rather than overlaying the +viewport, no tab needs a hard-coded `LibrariesChromeContentHeight` runway. +Remove the duplicate status-bar/chrome top padding from Recommended, Browse, +and Collections content. Each tab must still preserve its own internal spacing +and the existing bottom-chrome inset so its last item remains reachable. + +The optional hero artwork may continue painting behind the entire screen, +including behind the chrome. Only interactive and editorial scroll content is +confined below the chrome. This preserves the visual relationship between the +hero and header without allowing text or cards to pass under menu controls. + +## Interaction and State + +- Library switching, tab switching, search, Requests, Watch Together, + settings, profile/server switching, and sign-out behavior remain unchanged. +- Recommended retains its scroll position and hero selection behavior. +- Browse and Collections retain their filters, pagination, grids, and empty / + error states. +- The profile popup remains anchored to the profile button. Opening or closing + it must not change the content viewport or reset scroll position. +- System status-bar and display-cutout insets are consumed exactly once by the + chrome. + +## Verification + +Tests and validation must cover: + +- Structural/source coverage that all three canonical library subtabs share + one reserved chrome/content boundary. +- Recommended and Collections no longer contain scrollable top runway used to + clear the overlay. +- Browse no longer applies a second chrome/status-bar top inset. +- Loading, error, and empty states render below the chrome. +- Existing phone hero metadata and menu-order tests remain green. +- Phone release assembly succeeds. +- On a dedicated phone emulator when available: scroll Recommended, + Browse, and Collections; confirm content disappears at the chrome boundary, + the fixed chrome remains usable, and the profile popup does not move or + expose scrolling content beneath its anchor. + +Physical devices are excluded unless separately authorized. diff --git a/docs/superpowers/specs/2026-07-28-android-player-system-brightness-design.md b/docs/superpowers/specs/2026-07-28-android-player-system-brightness-design.md new file mode 100644 index 000000000..60aa34ab7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-android-player-system-brightness-design.md @@ -0,0 +1,53 @@ +# Android Player System Brightness Design + +## Decision + +The Android phone video player will stop overriding window brightness. Android +system and adaptive brightness remain authoritative while playback is open. + +## Current problem + +`PlayerGestureHandler` assigns a vertical drag beginning in the left 88 dp edge +to brightness control. The first drag converts the system-managed window value +into a fixed per-window brightness and later drags continue changing that +override. While the player is visible, Android's normal brightness control +appears ineffective because the window override wins. + +## Behavior + +- Remove the left-edge brightness drag mode and all writes to + `WindowManager.LayoutParams.screenBrightness`. +- A vertical drag beginning in the left edge performs no brightness action. +- Preserve the right-edge volume gesture. +- Preserve center swipe-down dismissal, double-tap seeking, pinch aspect-mode + changes, control toggling, and temporary fast-forward. +- Preserve `FLAG_KEEP_SCREEN_ON` while playing or buffering; it prevents sleep + and is independent of brightness. +- Do not change Android TV behavior, system settings, permissions, or adaptive + brightness. + +## Implementation + +Remove the `Brightness` member from `VerticalDragMode`, the +`adjustBrightness` helper, and their now-unused Android window imports. Retain +the left/right edge boundary only for routing the right edge to volume and the +center region to dismissal. The left edge resolves to `None`, so it cannot +accidentally dismiss playback. + +## Verification + +Automated coverage will prove that the mobile gesture implementation contains +no window-brightness mutation while retaining volume and dismissal routing. +Focused mobile player tests and the phone release assembly must pass. A Pixel +smoke check will confirm that Android's brightness control remains effective +during playback and that right-edge volume and center dismissal still work. +The Shield will not be installed or modified. + +## Success criteria + +- Opening and using the phone player never creates a per-window brightness + override. +- Android system/adaptive brightness continues controlling the display during + playback. +- Existing non-brightness player gestures and keep-screen-awake behavior do not + regress. diff --git a/docs/superpowers/specs/2026-07-28-android-subtitle-aspect-reconciliation-and-phone-sizing-design.md b/docs/superpowers/specs/2026-07-28-android-subtitle-aspect-reconciliation-and-phone-sizing-design.md new file mode 100644 index 000000000..095a042b7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-android-subtitle-aspect-reconciliation-and-phone-sizing-design.md @@ -0,0 +1,161 @@ +# Android Subtitle Aspect Reconciliation and Phone Sizing Design + +## Context + +PR #127 makes the shared Android subtitle canvas follow the displayed video +area in Fit, Fill, and Stretch modes. On a physical Pixel running the PR head, +changing Fill to Fit reproduced a remaining defect: subtitle cues stayed +vertically displaced and were clipped below the display. Depending on the cue +and dialogue gap, subtitles appeared enabled but absent. Playback, subtitle +selection, cue delivery, and video decoding remained healthy, and subsequent +captures showed the same track rendering normally in Fill mode. + +The same device validation also showed that the Android default subtitle size +is too small on a phone. The shared default is `Large`, but Android maps it to +`32 / 720` of the subtitle canvas, below Media3's default fractional size and +smaller than the shared model's nominal 56-point Large value. + +## Goals + +- Keep subtitle cues fully visible and correctly centered after every supported + aspect-mode transition. +- Make every phone subtitle-size preset legible at normal handheld viewing + distance while preserving the relative steps between presets. +- Preserve existing Android TV subtitle sizing. +- Preserve subtitle selection, cue styling, authored positioning, libass/ASS, + bitmap subtitle, letterbox, and title-safe behavior. + +## Non-goals + +- Changing subtitle tracks, server subtitle processing, or playback protocols. +- Changing the shared preset names or persisted subtitle appearance schema. +- Changing Android TV's existing font-size scale. +- Reimplementing Media3's aspect-ratio measurement algorithm. +- Adding unbounded frame callbacks, polling, delays, or timeout-based layout + workarounds. +- Reworking Android TV's existing subtitle remount transaction architecture. + +## Design + +### Stable post-layout reconciliation + +`SubtitleVideoRectSync` remains the single owner of subtitle-view geometry. An +aspect change may expose old `exo_content_frame` bounds during the immediate +Compose `AndroidView.update` callback. Mobile Fit and Stretch must not convert +those transitional bounds into fixed pixel dimensions: they set the subtitle +child to `MATCH_PARENT` with zero margins, allowing the Media3 content frame to +remeasure the subtitle child automatically. + +Mobile Fill maps to Media3 Zoom and still needs a parent-local visible crop +rectangle because its content frame extends beyond the viewport. That mode +continues to reconcile from the measured `exo_content_frame`. + +If the snapshot changes during that traversal, one further pre-draw +reconciliation is scheduled. The operation is generation-bound and capped at +two post-layout passes for each explicit sync request. A newer request replaces +the older generation, repeated requests coalesce, and detach/dispose cancels +pending work. No callback remains installed after the rectangle is stable or +the bound is reached. At the bound, the latest measured rectangle remains +applied; the permanent content-frame layout listener still handles any later +real layout change without spinning. + +The sync continues using Media3's measured `exo_content_frame` instead of +duplicating its aspect calculations. Fixed geometry remains expressed in the +subtitle view's parent-local coordinate space. Television title-safe and +letterbox insets retain their existing fixed-rectangle behavior; the +`MATCH_PARENT` shortcut applies only when both insets are absent. + +### Phone-only subtitle scaling + +Font-size conversion will accept an explicit Android presentation class: +`Phone` or `Television`. Phone uses a 1.125 multiplier over the current +fractions: + +| Preset | Phone | Television | +| --- | ---: | ---: | +| Small | 22.5 / 720 | 20 / 720 | +| Medium | 29.25 / 720 | 26 / 720 | +| Large | 36 / 720 | 32 / 720 | +| XLarge | 45 / 720 | 40 / 720 | +| XXLarge | 54 / 720 | 48 / 720 | + +The phone and TV dependency-injection modules construct `SubtitleManager` with +their fixed presentation class. The persisted preset remains unchanged, so an +existing `Large` preference becomes more legible on phone without a migration +and retains its current appearance on TV. + +Fractional sizing remains relative to the active subtitle canvas. It therefore +continues to respond naturally to orientation and displayed-video bounds. + +### Initial subtitle restore settlement + +On phone, restoring a persisted mounted subtitle must not treat Media3 +`Player.STATE_READY` as proof that its text-track catalog has settled. Media3 +can report ready while publishing an intermediate non-empty text-track +snapshot; failing the restore against that first snapshot produces a transient +error even though the requested track appears moments later. + +The phone player will follow the existing TV settlement rule: the first +non-empty text-track snapshot is provisional, a changed snapshot restarts +settlement, and only a repeated identical non-empty snapshot may prove that a +requested track is missing. A successful identity match still commits +immediately. The existing bounded mobile mount timeout remains the terminal +fallback when no stable success arrives. + +Android TV already implements this rule through +`TvSubtitleSnapshotSettlementTracker` and `SubtitleRemountReselection`; its +production path remains unchanged and receives focused regression coverage. + +## Correctness and lifecycle constraints + +- Immediate synchronization remains available for already-stable layouts. +- Reconciliation reads the current player, resize mode, video size, and content + frame on every pass; it must not apply a rectangle captured for an older + mode. +- At most one pre-draw listener exists per `PlayerView`. +- Detaching the view removes listeners and prevents late mutation. +- A replaced player cannot receive or influence later reconciliation. +- Existing cue forwarding and libass overlay attachment remain unchanged. + +## Testing + +Unit and mounted Robolectric coverage will prove: + +- Fill to Fit and Stretch to Fit settle to the final parent-local rectangle + without retaining a cropped top/left margin. +- Fit to Fill and rapid Fit/Fill/Stretch changes use the latest mode. +- A changed content-frame snapshot receives the bounded second pass. +- Stable geometry uses no extra pass, repeated explicit syncs coalesce, and + detach cancels pending work. +- Every phone preset is exactly 1.125 times its TV fraction. +- The default `Large` preset resolves to `36 / 720` on phone and `32 / 720` on + TV. +- Phone and TV construction paths select their intended presentation class. +- Mobile Fit and Stretch apply `MATCH_PARENT` dimensions and zero margins when + no title-safe or letterbox inset is configured. +- A stale Zoom crop followed immediately by Fit cannot retain its top/left + offsets, even before the Media3 parent completes its new layout. +- A restored phone subtitle cannot fail on the first non-empty Media3 + text-track snapshot, and a changed snapshot must stabilize again before it is + terminal. +- A matching restored phone subtitle commits as soon as it appears; a track + that never appears still fails through the existing bounded timeout. +- TV's first-snapshot and changed-snapshot settlement regressions remain green. + +Focused shared, phone, and TV subtitle tests will run first, followed by the +full unit suite and phone/TV release assemblies. Physical validation will use +the Pixel only and exercise Fit, Fill, Stretch, rapid transitions, multi-line +cues, and cue gaps. The Shield will not be installed or modified without +separate authorization. + +## Success criteria + +- The reproduced Fill-to-Fit cue is fully visible immediately after the sheet + closes and remains visible across subsequent cues. +- No supported aspect transition leaves stale subtitle margins or dimensions. +- Default phone subtitles sit between the original undersized build and the + rejected 1.25× build while all phone presets remain ordered and selectable. +- Restarting playback with a persisted subtitle does not show a transient mount + error while Media3 is still publishing text tracks. +- TV output, persistence, selection, styling, and subtitle formats show no + regression in automated verification. diff --git a/docs/superpowers/specs/2026-07-28-pr-126-coderabbit-remediation-design.md b/docs/superpowers/specs/2026-07-28-pr-126-coderabbit-remediation-design.md new file mode 100644 index 000000000..e3d00df42 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-pr-126-coderabbit-remediation-design.md @@ -0,0 +1,73 @@ +# PR #126 CodeRabbit Remediation Design + +## Goal + +Resolve every substantiated CodeRabbit finding on PR #126 without changing +deliberate request-sharing behavior or adding unrelated release scope. + +## Scope and approach + +The correction remains on `fix/tv-for-you-cold-navigation` and follows the +existing phone, TV, shared-repository, and Watch Together boundaries. Behavioral +changes are covered by focused regression tests before production changes. +Documentation and test-only cleanup remain separate from behavioral assertions +so failures identify the responsible correction. + +The implementation will: + +- Forward the shell Up fallback from the Alphabet library tab to its alphabet + rail, matching the Browse tab. +- Advance the For You entry request with a `null` selection whenever the + top-level For You destination is explicitly selected, so Watchlist or + Favorites state is not retained. +- Make the initial TV marquee seed sensitive to section identity while + preserving the rule that settled real focus always wins over a page-entry + seed. A refresh may update a stale page-entry identity, but must not replace + focused content. +- Accept IMDb ratings only when finite and within `(0, 10]` on both phone and + TV. Invalid rating and invalid duration cases will be tested independently. +- Make Watch Together delivery-key nullability explicit at the latch and player + reporting call sites without changing attach, cadence, or delivery semantics. +- Remove machine-specific paths from the committed SDD report and correct the + stale plan references and wording identified by CodeRabbit. +- Apply small behavior-preserving helper extractions only where they directly + address a review comment and reduce duplicated validation or test setup. +- Keep the existing shared in-flight recommendation request behavior. A + superseded caller may discard its result, but it must not cancel work shared + with another caller. + +## Finding disposition + +The Calendar fallback finding is already corrected on the current PR head and +will receive verification rather than another code change. The claimed +`RoomDeliveryLatch` compilation failure is disproven by both local compilation +and hosted Unit Tests; explicit null binding will nevertheless make the +invariant visible and remove the ambiguity that prompted the comment. + +Generic requests to split the PR or increase repository-wide docstring coverage +are not defects in the changed behavior and are outside this remediation. + +## Testing + +Focused tests will cover: + +- Alphabet and Browse fallback forwarding parity. +- Explicit For You root selection after Watchlist and Favorites entry. +- Marquee reseeding for a changed row identity, including protection of + real-focused content. +- Phone and TV rating upper bounds. +- Mixed valid-duration/invalid-rating and invalid-duration/valid-rating cases. +- Nullable and mismatched Watch Together delivery keys. + +After focused RED/GREEN cycles, the relevant phone, TV, shared, and +Android-shared unit suites will run, followed by the repository supply-chain +policy checks and phone/TV release compilation used by this branch. No APK will +be installed or deployed as part of this remediation. + +## Completion criteria + +The branch must be clean, all focused and full verification commands must pass, +an independent reviewer must report no unresolved critical or important issue, +and PR #126 must accurately reflect the added correction commit and current +check state. Proven false positives will be documented rather than addressed by +semantic changes. diff --git a/docs/superpowers/specs/2026-07-28-subtitle-aspect-recenter-design.md b/docs/superpowers/specs/2026-07-28-subtitle-aspect-recenter-design.md new file mode 100644 index 000000000..ecdec20bf --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-subtitle-aspect-recenter-design.md @@ -0,0 +1,81 @@ +# Subtitle Aspect-Mode Recentring Design + +## Problem + +On Android phone, changing video gravity from Fit to Fill or Stretch can leave +the subtitle layer using the previous fitted-video geometry. The visible video +then fills the player while subtitles remain positioned against stale bounds, +so ordinary centred subtitles appear off-centre. + +The phone and TV players both use `SubtitleManager` to align Media3, libass, and +bitmap subtitle rendering with the visible video viewport. Phone is the +confirmed reproduction. TV must receive the same shared correction because it +uses the same geometry owner, while its existing player wiring must be verified +independently. + +## Intended Behaviour + +- The subtitle canvas follows the final visible video viewport after every + aspect-mode change. +- Fit aligns the canvas with the fitted video rectangle. +- Fill and Stretch align the canvas with the full visible player viewport. +- Switching modes repeatedly cannot retain geometry from an earlier mode. +- Ordinary centred SRT/WebVTT cues remain centred in the new canvas. +- Authored ASS/SSA and PGS positions remain relative to the canvas. The client + does not rewrite individual cue positions or force every cue to centre. +- Existing letterbox detection, title-safe insets, subtitle appearance, and + transactional subtitle selection remain unchanged. + +## Design + +`SubtitleManager` remains the single geometry owner. Aspect-mode consumers +continue setting `PlayerView.resizeMode` and requesting a subtitle-bound sync. +The synchronizer must resolve bounds from the current resize mode and the +post-layout content frame, and it must schedule one bounded post-layout +reconciliation when a resize request can still expose the previous frame. + +The reconciliation is idempotent: it computes the desired rectangle, compares +it with the current subtitle layout parameters, and writes only when dimensions +or offsets differ. It does not introduce polling, arbitrary delays, or a second +subtitle renderer. + +The shared correction applies to both phone and TV. Platform screens retain +their existing aspect-mode mappings: + +- Phone Fill maps to Media3 Zoom; Stretch maps to Media3 Fill. +- TV Zoom and Stretch retain their existing mappings. + +## Lifecycle and Safety + +Any posted reconciliation is owned by the existing `PlayerView` synchronizer. +It is cancelled or made inert when the view detaches or the synchronizer is +disposed. A stale callback must not update a detached or replacement player +view. + +The change must not alter playback state, track selection, subtitle timing, +network requests, or persisted settings. + +## Verification + +Automated regression coverage will prove: + +- Fit computes fitted-video bounds. +- Fit to Fill and Fit to Stretch settle on full-viewport bounds. +- repeated mode switching does not retain stale offsets or dimensions; +- authored cue coordinates are not rewritten; +- phone and TV aspect-mode update paths both request shared subtitle + reconciliation; +- disposal prevents a delayed reconciliation from mutating a detached view. + +Focused shared, phone, and TV unit tests will run before both release variants +are assembled. On-device phone verification will switch among Fit, Fill, and +Stretch with a centred text subtitle and confirm visual recentring. TV will be +verified through focused tests and compilation; no Shield installation is +required unless separately requested. + +## Out of Scope + +- Changing subtitle appearance, size, vertical presets, or delay. +- Repositioning authored ASS/SSA or bitmap cues. +- Server, protocol, transcoding, or subtitle-format changes. +- Replacing Media3 or libass subtitle rendering. diff --git a/docs/superpowers/specs/2026-07-29-phone-director-credit-and-review-hardening-design.md b/docs/superpowers/specs/2026-07-29-phone-director-credit-and-review-hardening-design.md new file mode 100644 index 000000000..d16d616cd --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-phone-director-credit-and-review-hardening-design.md @@ -0,0 +1,71 @@ +# Phone Director Credit and Review Hardening Design + +**Date:** 2026-07-29 +**Target:** PR #129 (`feat/tv-detail-director-credit`) +**Base behavior:** PR #128 is already on `main`; PR #129 adds the TV movie-director credit. + +## Goal + +Bring PR #129's movie-director credit to Android phone with exact phone/TV parity, and close the concrete review gaps found while auditing PRs #128 and #129. The change must remain Android-client-only and must not alter server APIs, catalog models, or production purge behavior. + +## User-visible behavior + +For movie detail pages on both phone and TV, show one muted, single-line credit: + +`Directed by Name One, Name Two` + +The credit appears directly below the synopsis and optional description translation, and directly above the existing facts row. It is hidden for series, seasons, episodes, audiobooks, and other item types. + +Director selection uses crew entries whose trimmed job is exactly `Director`, case-insensitively. Names are trimmed, blank names are removed, duplicate names are removed while preserving server order, and at most three names are shown. Jobs such as `Director of Photography` do not qualify. The existing title-detail layout, cast/crew section, actions, and navigation remain unchanged. + +## Architecture + +Move the pure director-selection and text-formatting rule to a small Android-shared presentation helper used by both `androidApp` and `androidTvApp`. This gives the two Android clients one rule without moving display policy into the shared catalog model. The phone and TV hero composables retain platform-specific typography and layout, but receive the same nullable formatted string. + +The phone `DetailHero` gains an optional director-credit parameter. `MovieDetailContent` supplies the shared helper result; series and other phone detail paths do not. TV replaces its local extraction implementation with the shared helper and preserves its existing rendering. + +## Review hardening + +### PR #129 director coverage + +Focused pure tests will cover: + +- movie-only behavior; +- exact, case-insensitive `Director` job matching; +- trimmed names; +- blank-name removal; +- stable de-duplication; +- the three-name cap; and +- no credit when no qualifying director exists. + +Phone and TV source/wiring tests will ensure both movie hero paths use the shared credit and that the existing TV and phone placement remains between synopsis/translation and facts. + +### PR #128 runtime coverage + +Existing phone and TV hero-metadata tests will be extended to prove that: + +- positive catalog `runtime` minutes take precedence over playback `durationSeconds`; and +- absent or invalid catalog runtime falls back to `durationSeconds`. + +This adds regression coverage only; PR #128's shipped production behavior is not redesigned. + +### Hosted purger-test race + +The failed hosted Unit tests check is a baseline test-harness race, not a PR #129 production regression. The test currently asserts that the removed server's row still exists after the second purge may already have deleted it. + +The test will gain explicit deferred gates around the second purge's row deletion. It will wait until the second pass is demonstrably selected, assert the row still exists while deletion is held, release deletion, then assert the row is removed. This makes the intended snapshot/second-pass ordering deterministic without widening a timeout or changing `OrphanedServerDataPurger` production semantics. + +## Verification + +Verification will include the focused director, phone runtime, TV runtime, and purger tests; the relevant module unit-test tasks; Android phone and TV compilation; supply-chain policy checks required by the repository; and a diff audit confirming no server/protocol or production purge changes. + +PR #129 will be updated but not merged. + +## Non-goals + +- Director credits for series, seasons, episodes, or audiobooks. +- Changes to server crew metadata or API contracts. +- Changes to the full cast/crew section. +- New navigation or detail-page structure. +- Production purge behavior changes. +- Timeout increases or retries that conceal the hosted test race. diff --git a/docs/superpowers/specs/2026-07-29-remove-tv-detail-starring-overlay-design.md b/docs/superpowers/specs/2026-07-29-remove-tv-detail-starring-overlay-design.md new file mode 100644 index 000000000..c91f682f6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-remove-tv-detail-starring-overlay-design.md @@ -0,0 +1,85 @@ +# Remove Android TV Detail Starring Overlay + +**Date:** 2026-07-29 +**Status:** Approved + +## Purpose + +Remove the floating `Starring …` credit from the upper-right of the Android TV +item-detail hero. The credit duplicates the full cast and crew section lower on +the same page, competes with backdrop artwork, and remains difficult to make +consistently legible across arbitrary imagery. + +The resulting hero should preserve a clearer hierarchy: + +1. title and primary metadata; +2. synopsis and optional translation; +3. movie-only `Directed by …` credit; +4. facts and actions. + +## Scope + +- Stop deriving and passing `starringText` into `TvDetailHero`. +- Remove the `starringText` parameter and its upper-right text overlay from + `TvDetailHero`. +- Remove the now-unused `TvDetailMetadata.starringText` helper and its focused + unit coverage. +- Preserve `TvCastCrewSection` on the TV detail page as the complete cast and + crew presentation. +- Preserve the movie-only `Directed by …` credit on Android TV and phone. +- Preserve the existing title-detail content, actions, focus behavior, hero + gradients, synopsis, translation, and fact tokens. + +## Client Impact + +This is intentionally an Android TV-only visual simplification. Android phone +does not have the floating upper-right starring overlay, so no phone production +UI changes are required. Both clients retain their existing cast and crew +content and the shared movie director-credit behavior. + +No server, API, model, persistence, navigation, playback, or Apple-client +changes are included. + +## Behavior + +For every supported TV item type, the upper-right hero area is left to the +backdrop artwork. Cast data remains available by scrolling to the existing cast +and crew section. Empty or absent cast data behaves exactly as before outside +the removed overlay. + +No replacement shadow, glyph halo, localized vignette, panel, or inline +`Starring …` row is introduced. This avoids adding visual machinery for +duplicated metadata. + +## Implementation Boundary + +The change should remain within the TV detail presentation and its focused +metadata tests: + +- `androidTvApp/.../detail/TvItemDetailScreen.kt` +- `androidTvApp/.../detail/TvDetailHero.kt` +- `androidTvApp/.../detail/TvDetailMetadata.kt` +- `androidTvApp/.../detail/TvDetailMetadataTest.kt` + +If source-level tests directly assert the removed parameter or call site, update +them narrowly. Do not refactor unrelated hero layout or metadata formatting. + +## Verification + +- Focused TV detail metadata/source tests confirm the starring helper and hero + wiring are gone while director-credit ordering remains intact. +- Android TV unit tests pass. +- Android TV debug and release compilation succeeds. +- A TV/emulator detail-page smoke check confirms: + - no floating upper-right starring credit; + - cast and crew remains available below; + - `Directed by …` remains between synopsis/translation and facts for movies; + - hero focus, actions, and scrolling are unchanged. + +## Acceptance Criteria + +- No `Starring …` overlay appears in the Android TV detail hero. +- The existing cast and crew section is unchanged and remains reachable. +- The movie director credit remains unchanged on phone and TV. +- No substitute contrast treatment or actor-credit placement is added. +- No phone, server, protocol, playback, or persistence behavior changes. diff --git a/docs/superpowers/specs/2026-07-29-section-cache-profile-switch-test-race-design.md b/docs/superpowers/specs/2026-07-29-section-cache-profile-switch-test-race-design.md new file mode 100644 index 000000000..71ea522e5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-section-cache-profile-switch-test-race-design.md @@ -0,0 +1,47 @@ +# Section Cache Profile-Switch Test Race Design + +**Date:** 2026-07-29 +**Status:** Approved + +## Problem + +The post-merge `main` workflow failed +`SectionRepositoryCacheTest.homeRequestStartedBeforeProfileSwitchDoesNotServeOldProfileSectionsToNewProfile` +with `expected Old, got New`. + +The shared `gatedRepository` test helper currently: + +1. increments the request counter; +2. completes `requestEntered`, which can resume the waiting test coroutine; +3. derives the response body from the mutable counter. + +The resumed coroutine can start request two between steps 2 and 3. Request one +then observes the second request's counter value and receives the wrong fixture. +This is a test-harness scheduling race; the failed branch and merge commit have +identical Git trees, and PR #130 did not change shared production or test code. + +## Design + +Capture each request's response body immediately after `onRequest()` and before +completing `requestEntered`. Only then expose the request to the waiting test and +block on `releaseResponse`. + +This assigns the fixture deterministically to the request that incremented the +counter while preserving the helper's existing request-entry and release gates. + +## Scope + +- Modify only `gatedRepository` in + `shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/SectionRepositoryCacheTest.kt`. +- Do not change `SectionRepository`, identity-transition behavior, production + code, timeouts, worker counts, or application binaries. +- Do not weaken or remove the profile-isolation assertions. + +## Verification + +- Use the hosted failure as the RED evidence: request one received `New`. +- Run the exact failed test repeatedly under `--max-workers=2 --rerun-tasks + --no-daemon`. +- Run the complete `SectionRepositoryCacheTest` class. +- Run the complete shared debug unit suite under the hosted two-worker shape. +- Confirm the final diff is test-only and whitespace-clean. diff --git a/docs/superpowers/specs/2026-07-29-specials-first-season-order-design.md b/docs/superpowers/specs/2026-07-29-specials-first-season-order-design.md new file mode 100644 index 000000000..c30f01ab1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-specials-first-season-order-design.md @@ -0,0 +1,69 @@ +# Android Specials-First Season Order Design + +## Goal + +Match the web client’s season-selector presentation on Android phone and TV: + +`Specials, Season 1, Season 2, …` + +The visible label remains **Specials**. Android must not relabel it as +“Season 0.” + +## Scope + +- Android phone and Android TV series-detail season selectors. +- The existing shared `List.sortedForDisplay()` ordering contract. +- Initial season selection when opening a series. +- Focused unit tests for ordering and selection behavior. + +The Prairie server, web client, Apple clients, API schema, and playback sequencing +are unchanged. + +## Ordering Contract + +A season is treated as Specials when either: + +- `isSpecials` is `true`; or +- `seasonNumber` is `0`. + +Specials sorts before every regular season. Regular seasons sort by +`seasonNumber` ascending. Existing deterministic title and content-ID +tie-breakers remain in place. + +Recognizing Season 0 independently of `isSpecials` protects the UI when reading +older cached responses or a response that omitted the optional semantic flag. + +## Initial Selection + +Display order and automatic selection are separate: + +- A requested/deep-linked season remains selected, including Specials. +- On an ordinary series opening, select the first regular season. +- If the series contains only Specials, select Specials. + +This prevents the reordered selector from making a series open on bonus +material by default while still placing Specials first visually. + +## Implementation Shape + +Update the existing shared season comparator rather than reordering separately +inside phone and TV composables. Keep the phone and TV view models responsible +for choosing the initial season, using the same “first regular, otherwise +first” rule after applying the shared display order. + +No new repository, model, route, or server behavior is introduced. + +## Verification + +Focused tests cover: + +- Season 0 before Seasons 1 and 2. +- `isSpecials = true` before regular seasons even with a nonzero number. +- Season 0 recognized when `isSpecials` is false or absent. +- Regular seasons remain ascending and deterministically ordered. +- Phone and TV initially select the first regular season. +- A requested Specials season remains selected. +- Specials-only series still select Specials. + +Run the affected shared, phone-detail, and TV-detail unit tests, followed by +phone and TV debug compilation. diff --git a/docs/superpowers/specs/2026-07-30-playback-buffer-architecture-design.md b/docs/superpowers/specs/2026-07-30-playback-buffer-architecture-design.md new file mode 100644 index 000000000..d8cc31332 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-playback-buffer-architecture-design.md @@ -0,0 +1,157 @@ +# Playback Buffer Architecture — Design + +**Date:** 2026-07-30 +**Status:** Approved in design conversation; this document is its record +**Scope:** `android-shared` playback buffering (phone + TV share it) + +## Why + +Two problems, one of which masks the other. + +**1. The buffer fills, the socket idles, the proxy kills the connection.** +`DefaultLoadControl` loads until `maxBufferMs`, then stops reading the socket +until the buffer drains below `minBufferMs`. The connection therefore sits idle +for roughly `max − min` of playback time. Today's hardcoded policy is +`min 50s / max 120s` — a **70-second idle window** against a proxy +`send_timeout` that defaults to 60s. The connection is dropped whenever the +buffer fills on a long direct-play file, and the client only discovers it when +it comes back for more data. This is arithmetic, not a race. + +**2. The byte cap silently overrides the time target.** +`prioritizeTimeOverSizeThresholds` is `false`, so whichever limit binds first +wins. With device-class caps of 48/96/160 MiB, a 40–80 Mbps remux gets roughly +10–34 seconds of buffer while the configuration claims 50. Nothing surfaces the +discrepancy. + +These interact: the premature byte cap has been *shortening the idle window*, +partly hiding problem 1. Raising the caps without fixing the window would make +dropped connections dramatically more common. + +Additionally, the three-mode `PlaybackBufferMode` enum is dead code — +`SiloPlayerFactory` hardcodes `Balanced`, so `QuickStart`, `SmoothPlayback` and +the `fromWire` parsing are unreachable. + +## Decisions + +Taken in conversation with Jim: + +- **Automatic, from measured conditions.** No user setting, no server-driven + wire value. The player derives the policy from what it can observe. +- **Start fast, then deepen.** Begin on a small cushion and fill in the + background; users judge a player on time-to-first-frame. +- **Depth and idle window are independent.** Extend the buffer as far as memory + and throughput allow, while holding the idle window fixed. + +## Architecture + +### The invariant + +`maxBufferMs` stops being a free parameter: + +```text +maxBufferMs = minBufferMs + MAX_LOAD_IDLE_MS +``` + +`MAX_LOAD_IDLE_MS = 15_000`, which is a 30s wall-clock budget scaled *down* by +the slowest selectable playback rate (`30_000 * 0.5`). The 30s budget sits well under the assumed +60s upstream proxy `send_timeout`, but the invariant is expressed in *media* +time while a proxy measures *wall clock*, and `DefaultLoadControl` scales +`minBufferUs` only for speeds above 1.0. Audiobooks offer 0.5x and share this +load control, so a 30s media window would stretch to 60s of wall clock — +exactly the timeout. The assumed timeout and the slowest rate are both named +constants with their reasoning beside them, so a deployment behind a 30s proxy +has an obvious dial rather than a mystery. + +This makes the failure structurally unrepresentable: no matter how deep the +buffer grows, the socket cannot idle long enough to be dropped. Depth is +`minBufferMs`; the window is the gap. + +### Depth is governed by memory and throughput + +Depth grows toward a ceiling, bounded by: + +- **Memory budget** — bytes needed = target seconds × observed bitrate, + clamped to a fraction of the app heap. When the budget cannot fund the target + seconds, the *target seconds are reduced explicitly* to what fits. + **20s is the policy's requested floor, not a guarantee:** it is where depth + starts when nothing constrains it, but a known bitrate high enough that the + budget funds less than 20s yields the smaller, honest number. Raising it back + to 20s would only mean claiming a depth the memory cannot hold — which is the + silent overrun this work exists to remove. `maxBufferMs` follows `min` down, + so the idle window only ever shrinks. +- **Delivery throughput** — the bandwidth meter already reports delivery rate. + Delivery ≫ media bitrate means the source can outrun playback (direct file, + or a fast/GPU transcode) and depth may extend. Delivery ≈ bitrate means the + producer is realtime-bound and the buffer cannot grow regardless of target. +- **Ceiling: 180s.** Beyond this we are mostly pre-fetching content the user may + seek away from — wasted bandwidth, and wasted allowance on mobile data. + +### Transcode needs no special case + +Investigated and deliberately dropped. Two findings: + +1. A deep target does **not** make the client wait on the encoder — ExoPlayer + simply receives more slowly. If the encoder is realtime-bound the buffer + never reaches `max`, so the socket never idles and problem 1 cannot occur on + transcoded streams at all. A deep target on a slow encoder is inert. +2. The **server already bounds it**. `TranscodeThrottler` + (`internal/playback/throttle.go` in prairie-server) pauses ffmpeg once it is + `transcode_throttle_seconds` ahead of the client's fetch position — default + **300s**, clamped to a 60s minimum, gated by `enable_transcode_throttle`. + That is the real ceiling for transcoded content, and it is the server's to + enforce. + +So throughput-driven depth handles transcode without the client knowing what it +is talking to: a GPU-transcoding server behaves like direct play, a CPU-bound +one degrades gracefully, and nothing breaks when stream nodes are enabled later. + +Note that HLS delivery (remux or transcode) fetches discrete segments, so each +request is short-lived and the idle-window problem does not arise there. The +invariant is harmless in that case and load-bearing for `ORIGINAL_HTTP` +progressive direct play — which is exactly where the reported drops occur. + +### Numbers + +| | Start | After stall | Depth (min) | Idle window | +|---|---|---|---|---| +| All delivery | 2s | 5s | 20s requested floor → 180s ceiling, memory/throughput governed | 15s media (30s wall clock at 0.5x) | + +Start drops 3s → 2s. Stall recovery drops 10s → 5s: after a stall the user is +watching a spinner, and ten seconds is a long time to withhold the picture for +insurance. + +## Components + +Three units, each independently testable: + +- **`PlaybackBufferPolicy`** — the value type, plus a pure + `forConditions(deviceProfile, ...)` replacing `forMode(...)`. Where the + numbers live. `PlaybackBufferMode` and its `fromWire` parsing are deleted. +- **`PrairieLoadControl`** — keeps bitrate-aware byte sizing; gains the + seconds-fit-to-budget reduction and enforces the idle-window invariant when + constructing its `DefaultLoadControl` parameters. +- **`SiloPlayerFactory`** — stops naming a mode; passes observed conditions. + +## Testing + +Pure functions, following the existing `PlaybackBufferPolicyTest` / +`PrairieLoadControlTest` pattern. Per this repo's guidelines, focused tests on +high-risk behaviour only: + +- The idle-window invariant holds for every reachable policy — including after + the memory budget has forced depth down. +- Seconds-fit-to-budget reduction on a low-RAM device with a 60 Mbps stream: + the reported depth is the honest sub-floor number the budget funds, not the + 20s the policy asked for. +- The 180s ceiling holding when memory would allow more. +- A regression pinning `max − min == MAX_LOAD_IDLE_MS`, since that is the property that + prevents the dropped connections. + +## Out of scope + +- Any user-facing or server-driven buffer setting. +- LAN-vs-remote branching: no such signal exists in the player today. +- HLS-vs-progressive policy split: the throughput signal covers what matters. +- Changing `proxy_send_timeout` on openresty. That would help only servers Jim + controls; the client-side invariant holds against any proxy, including + users' own reverse proxies and CDNs. diff --git a/shared/src/androidMain/kotlin/org/prairieserver/prairie/model/settings/LanguagePresentation.android.kt b/shared/src/androidMain/kotlin/org/prairieserver/prairie/model/settings/LanguagePresentation.android.kt new file mode 100644 index 000000000..1e802b81f --- /dev/null +++ b/shared/src/androidMain/kotlin/org/prairieserver/prairie/model/settings/LanguagePresentation.android.kt @@ -0,0 +1,28 @@ +package org.prairieserver.prairie.model.settings + +import java.util.Locale + +internal actual fun localizedLanguageName(tag: String): String { + val locale = Locale.forLanguageTag(tag.replace('_', '-')) + return locale.getDisplayName(Locale.getDefault()).takeIf { it.isNotBlank() } + ?: tag +} + +internal actual fun canonicalLanguageIdentity(tag: String): String { + val parts = tag.replace('_', '-').split('-').toMutableList() + if (parts.isEmpty()) return tag.lowercase(Locale.ROOT) + + val primary = parts.first().lowercase(Locale.ROOT) + val canonicalPrimary = if (primary.length == 3) { + Locale.getISOLanguages().firstOrNull { twoLetter -> + runCatching { + Locale.forLanguageTag(twoLetter).isO3Language.equals(primary, ignoreCase = true) + } + .getOrDefault(false) + } ?: primary + } else { + primary + } + parts[0] = canonicalPrimary + return parts.joinToString("-").lowercase(Locale.ROOT) +} diff --git a/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/model/settings/SettingsConformanceTest.kt b/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/model/settings/SettingsConformanceTest.kt new file mode 100644 index 000000000..8a7d5335a --- /dev/null +++ b/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/model/settings/SettingsConformanceTest.kt @@ -0,0 +1,389 @@ +package org.prairieserver.prairie.model.settings + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The Kotlin runner for the cross-platform settings conformance fixture — the + * contract's named drift gate. + * + * The same hand-authored cases in `contracts/settings/v1/conformance.json` run + * against the Go resolver (`internal/settingsresolve/conformance_test.go`), the + * TypeScript one (`web/src/lib/settingsConformance.test.ts`), this one, and + * Swift in the Apple clients. Four independently written resolvers agreeing on + * every case is the whole point, so this runner takes the fixture at face + * value: it decodes strictly, resolves through [resolveSettingValues] against + * the real vendored manifest, and compares every declared expectation. + * + * Both JSON files are vendored byte-identically from the server repo; see the + * SOURCE file beside them. Nothing here touches the network. + * + * Four things fail this suite, and each of them is drift: + * + * 1. A resolution disagreement — this client would show a user a different + * effective setting than the server resolves. + * 2. A manifest revision mismatch between the fixture, the vendored manifest, + * and the generated [SettingKeys]. A revision bump changes definitions, so + * the expectations have to be re-derived rather than assumed to still hold. + * 3. A key the bindings and the vendored manifest disagree about, which catches + * the two JSON files being vendored from different server commits — skew the + * revision check cannot see, since a revision only moves on a manifest PR + * and both copies would still read the same number. + * 4. A fixture field this runner does not know. Schema drift in the fixture is + * itself drift: a field one platform reads and another silently skips means + * the platforms are no longer running the same cases, which is precisely the + * failure the fixture exists to prevent. Strictness here is not pedantry — + * it is the only thing keeping a silent skip from looking like a pass. + */ +class SettingsConformanceTest { + + // Strict by construction: kotlinx rejects unknown keys and missing required + // fields at every level of the tree by default, which is the unknown-field + // gate for everything below. The places JSON null is a *value* rather than + // an absence — a stored row's value, and an expectation's value and + // stored_value — are typed as non-nullable JsonElement so an authored null + // decodes to JsonNull instead of collapsing onto the Kotlin null an + // omission produces. + private val strictJson = Json { + ignoreUnknownKeys = false + isLenient = false + coerceInputValues = false + } + + @Serializable + private data class ConformanceFixture( + @SerialName("fixture_version") val fixtureVersion: Int, + @SerialName("manifest_revision") val manifestRevision: Int, + val description: String, + val cases: List, + ) + + @Serializable + private data class ConformanceCase( + val name: String, + val description: String? = null, + val keys: List, + val context: ConformanceContext? = null, + val stored: List = emptyList(), + // Policy inputs by name, as the policy layer would supply them. Keys + // here are data, not schema, so they are deliberately not field-checked. + val constraints: Map = emptyMap(), + // Attaches a constraint to a copy of a real definition, so constraint + // kinds no shipped definition carries stay testable. + @SerialName("constraint_bindings") val constraintBindings: List = emptyList(), + val expected: List, + ) + + @Serializable + private data class ConformanceContext( + @SerialName("profile_id") val profileId: String? = null, + @SerialName("device_id") val deviceId: String? = null, + @SerialName("library_ids") val libraryIds: List = emptyList(), + @SerialName("series_ids") val seriesIds: List = emptyList(), + ) + + @Serializable + private data class ConformanceRow( + val key: String, + val scope: String, + @SerialName("profile_id") val profileId: String? = null, + @SerialName("device_id") val deviceId: String? = null, + @SerialName("library_id") val libraryId: Int? = null, + @SerialName("series_id") val seriesId: String? = null, + val value: JsonElement, + ) + + @Serializable + private data class ConformanceBinding( + val key: String, + @SerialName("policy_input") val policyInput: String, + val constraint: SettingConstraintKind, + ) + + @Serializable + private data class ConformanceExpected( + val key: String, + val value: JsonElement, + val source: String, + /** Defaults to false; when true, stored_value and constraint_kind must be present. */ + val constrained: Boolean = false, + // Typed non-nullable so an authored `"stored_value": null` — which two + // bitrate cases rely on — decodes to JsonNull rather than collapsing + // onto the same Kotlin null an omission produces. Presence itself is + // gated in [expectationsSpellNullRatherThanOmittingIt], which reads the + // raw tree; kotlinx cannot express the distinction here. + @SerialName("stored_value") val storedValue: JsonElement = JsonNull, + @SerialName("constraint_kind") val constraintKind: SettingConstraintKind? = null, + ) + + private val manifest: SettingsManifest by lazy { + // The manifest is a vendored copy pinned by its revision rather than by + // field-level strictness, so unknown fields here are tolerated: the + // server may add advisory metadata in a revision this client still + // understands, and only the fields resolution reads are modelled. + Json { ignoreUnknownKeys = true } + .decodeFromString(resource("settings/v1/manifest.json")) + } + + private val fixtureRaw: String by lazy { resource("settings/v1/conformance.json") } + + private val fixture: ConformanceFixture by lazy { + strictJson.decodeFromString(fixtureRaw) + } + + @Test + fun theFixtureTargetsThisBuildsManifestRevision() { + assertEquals(1, fixture.fixtureVersion, "this runner understands fixture_version 1") + + // Three copies of one number, and they are only equal by maintenance: + // the fixture, the manifest it was authored against, and the bindings + // generated from that manifest. A bump to any one without the others is + // a client resolving against a contract it no longer carries. + assertEquals( + manifest.revision, + fixture.manifestRevision, + "the fixture targets manifest revision ${fixture.manifestRevision} but the vendored " + + "manifest is revision ${manifest.revision}; re-vendor both and re-derive the " + + "fixture expectations", + ) + assertEquals( + SettingKeys.REVISION, + manifest.revision, + "the vendored manifest is revision ${manifest.revision} but the generated bindings " + + "are revision ${SettingKeys.REVISION}; re-run make settings-bindings on the " + + "server and re-vendor", + ) + assertTrue(fixture.cases.isNotEmpty(), "the fixture declares no cases") + } + + @Test + fun theVendoredManifestCoversTheGeneratedBindings() { + // The bindings are generated from this manifest, so every remote key + // must resolve against it. A key in one and not the other means the two + // files were vendored from different server commits — the exact drift + // the revision check cannot catch, because a revision is only bumped by + // a manifest PR and both copies would still read 1. + for (key in SettingKeys.REMOTE) { + val definition = assertNotNull(manifest.lookup(key), "$key is not in the manifest") + assertTrue(definition.isRemote, "$key is remote in the bindings, not in the manifest") + } + for (key in SettingKeys.CLIENT_LOCAL) { + val definition = assertNotNull(manifest.lookup(key), "$key is not in the manifest") + assertFalse( + definition.isRemote, + "$key is client_local in the bindings but remote in the manifest", + ) + } + } + + @Test + fun generatedPresentationMetadataMatchesTheVendoredManifest() { + for ((key, presentation) in SettingPresentationMetadata.DEFINITIONS) { + val definition = assertNotNull(manifest.lookup(key), "$key is not in the manifest") + assertEquals(definition.suggestedOptions, presentation.suggestedOptions) + assertEquals(definition.unsetLabel, presentation.unsetLabel) + + val setId = definition.suggestedOptions ?: continue + val optionSet = assertNotNull(manifest.optionSets[setId], "$setId is not in the manifest") + assertEquals("language_tag", optionSet.type) + assertEquals( + optionSet.options + .filter { it.introducedIn <= manifest.revision } + .map { it.value }, + SettingPresentationMetadata.suggestedValues(key), + ) + } + } + + @Test + fun everyCaseIsDeclaredOnce() { + val names = fixture.cases.map { it.name } + assertFalse(names.any { it.isBlank() }, "a conformance case has no name") + assertEquals(names.size, names.toSet().size, "duplicate conformance case name") + } + + /** + * The null-vs-absent gate that strict decoding cannot express. + * + * `value` is required, so decoding already caught an omission. `stored_value` + * is the hard one: it may legitimately be JSON null when present, and + * kotlinx collapses "absent" and "explicit null" onto the same Kotlin null. + * So presence is read off the raw tree, matching what the Go runner gets + * from a nil json.RawMessage and the web runner from an `in` check. + */ + @Test + fun expectationsSpellNullRatherThanOmittingIt() { + val cases = strictJson.parseToJsonElement(fixtureRaw).jsonObject + .getValue("cases").jsonArray + assertEquals(fixture.cases.size, cases.size) + + cases.forEachIndexed { caseIndex, rawCase -> + val case = fixture.cases[caseIndex] + val rawObject = rawCase.jsonObject + + rawObject["stored"]?.jsonArray?.forEachIndexed { rowIndex, rawRow -> + assertTrue( + "value" in rawRow.jsonObject, + "${case.name}: stored[$rowIndex] must spell an authored null as null", + ) + } + + rawObject.getValue("expected").jsonArray.forEachIndexed { index, rawExpected -> + val expectation = case.expected[index] + val fields = rawExpected.jsonObject + assertTrue( + "value" in fields, + "${case.name}: expected[$index] must spell an expected null as null", + ) + if (expectation.constrained) { + assertTrue( + "stored_value" in fields, + "${case.name}: ${expectation.key}: a constrained expectation must " + + "declare stored_value", + ) + assertNotNull( + expectation.constraintKind, + "${case.name}: ${expectation.key}: a constrained expectation must " + + "declare constraint_kind", + ) + } else { + assertFalse( + "stored_value" in fields, + "${case.name}: ${expectation.key}: an unconstrained expectation must " + + "not declare stored_value", + ) + assertNull( + expectation.constraintKind, + "${case.name}: ${expectation.key}: an unconstrained expectation must " + + "not declare constraint_kind", + ) + } + } + } + } + + @Test + fun everyCaseResolvesToItsExpectedEffectiveValues() { + for (case in fixture.cases) { + assertTrue(case.keys.isNotEmpty(), "${case.name}: declares no keys") + assertTrue(case.expected.isNotEmpty(), "${case.name}: declares no expectations") + + val bindings = mutableMapOf() + for (binding in case.constraintBindings) { + assertNotNull( + manifest.lookup(binding.key), + "${case.name}: constraint binding names unknown key ${binding.key}", + ) + assertTrue( + binding.policyInput.isNotBlank(), + "${case.name}: constraint binding on ${binding.key} has no policy_input", + ) + assertNull( + bindings.put( + binding.key, + SettingConstraintBinding(binding.policyInput, binding.constraint), + ), + "${case.name}: duplicate constraint binding for ${binding.key}", + ) + } + + val resolved = resolveSettingValues( + manifest = manifest, + keys = case.keys, + stored = case.stored.map { row -> + StoredSettingRow( + key = row.key, + scope = row.scope, + profileId = row.profileId, + deviceId = row.deviceId, + libraryId = row.libraryId, + seriesId = row.seriesId, + value = row.value, + ) + }, + context = SettingResolutionContext( + profileId = case.context?.profileId, + deviceId = case.context?.deviceId, + libraryIds = case.context?.libraryIds.orEmpty(), + seriesIds = case.context?.seriesIds.orEmpty(), + ), + constraints = case.constraints, + constraintBindings = bindings, + ) + + assertEquals( + case.expected.size, + resolved.size, + "${case.name}: resolved ${resolved.size} settings, the fixture expects " + + "${case.expected.size}", + ) + val byKey = resolved.associateBy { it.key } + + for (expectation in case.expected) { + val entry = assertNotNull( + byKey[expectation.key], + "${case.name}: no resolved value for ${expectation.key}", + ) + val where = "${case.name}: ${expectation.key}" + assertTrue( + jsonEquivalent(entry.value, expectation.value), + "$where: value = ${entry.value}, want ${expectation.value}", + ) + assertEquals(expectation.source, entry.source, "$where: source") + assertEquals(expectation.constrained, entry.constrained, "$where: constrained") + assertEquals( + expectation.constraintKind, + entry.constraintKind, + "$where: constraint_kind", + ) + if (expectation.constrained) { + val storedValue = assertNotNull( + entry.storedValue, + "$where: a constrained result must report stored_value", + ) + assertTrue( + jsonEquivalent(storedValue, expectation.storedValue), + "$where: stored_value = $storedValue, want ${expectation.storedValue}", + ) + } else { + assertNull( + entry.storedValue, + "$where: stored_value reported without a constraint", + ) + } + } + } + } + + @Test + fun anUnknownFixtureFieldFails() { + // The unknown-field gate is the one thing here that would otherwise be + // untested — it only ever fires on a fixture this repo does not carry + // yet, so its own regression would be invisible until the day it was + // needed. Injecting a field proves strict decoding is actually on. + val drifted = fixtureRaw.replaceFirst("\"cases\":", "\"cases_v2\": [], \"cases\":") + assertFalse(drifted == fixtureRaw, "failed to inject a drifted field") + val failure = runCatching { strictJson.decodeFromString(drifted) } + assertTrue( + failure.isFailure, + "an unknown fixture field decoded cleanly; strict decoding is off and drift in the " + + "fixture schema would pass silently", + ) + } + + private fun resource(path: String): String = + checkNotNull(javaClass.classLoader?.getResource(path)) { + "Missing test resource $path" + }.readText() +} 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..991db874e 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt @@ -14,6 +14,7 @@ val networkModule = module { single { TokenManagerImpl(get()) } single { createPrairieClient(get(), getOrNull(), getOrNull(), getOrNull()) } single { AuthApi(get()) } + single { OnboardingApi(get()) } single { DefaultDeviceLoginApi(get()) } single { CatalogApi(get()) } single { PlaybackApi(get()) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/RepositoryModule.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/RepositoryModule.kt index 9a74ae010..f8377aa04 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/RepositoryModule.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/RepositoryModule.kt @@ -8,6 +8,7 @@ import org.prairieserver.prairie.model.feature.RequestsFeatureStore import org.prairieserver.prairie.repository.LiveTvRepository import org.prairieserver.prairie.repository.AdminRepository import org.prairieserver.prairie.repository.AuthRepository +import org.prairieserver.prairie.repository.OnboardingRepository import org.prairieserver.prairie.repository.CalendarRepository import org.prairieserver.prairie.repository.DeviceLoginRepository import org.prairieserver.prairie.repository.CatalogRepository @@ -28,6 +29,7 @@ import org.prairieserver.prairie.repository.SettingsRepository import org.prairieserver.prairie.repository.WatchTogetherRepository import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.watchtogether.RoomSession +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway import org.koin.dsl.module import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -49,8 +51,16 @@ val repositoryModule = module { // (commonMain tests, hypothetical iOS reuse). Both repos no-op the // multi-server side effects when the registry is null. single { AuthRepository(get(), get(), getOrNull(), getOrNull()) } + single { OnboardingRepository(get()) } single { DeviceLoginRepository(get()) } - single { CatalogRepository(get(), getOrNull() ?: org.prairieserver.prairie.repository.port.NoOpCatalogCachePort) } + single { + CatalogRepository( + catalogApi = get(), + catalogCache = getOrNull() + ?: org.prairieserver.prairie.repository.port.NoOpCatalogCachePort, + identityTransitions = get(), + ) + } single { CalendarRepository(get()) } single { PlaybackRepository(get()) } // `getOrNull()` picks up the Room-backed ports when the Android platform @@ -58,14 +68,24 @@ val repositoryModule = module { // back to the network-only no-op ports in commonMain tests / when unbound. single { PersonalDataRepository( - get(), - getOrNull() ?: org.prairieserver.prairie.repository.port.NoOpUserItemStatePort, - getOrNull() ?: org.prairieserver.prairie.repository.port.NoOpCatalogCachePort, + personalDataApi = get(), + userItemStatePort = getOrNull() + ?: org.prairieserver.prairie.repository.port.NoOpUserItemStatePort, + catalogCache = getOrNull() + ?: org.prairieserver.prairie.repository.port.NoOpCatalogCachePort, + identityTransitions = get(), ) } single { ProfileRepository(get(), get(), getOrNull(), get(), get(), get()) } single { CollectionRepository(get()) } - single { SectionRepository(get(), getOrNull() ?: org.prairieserver.prairie.repository.port.NoOpCatalogCachePort) } + single { + SectionRepository( + sectionApi = get(), + catalogCache = getOrNull() + ?: org.prairieserver.prairie.repository.port.NoOpCatalogCachePort, + identityTransitions = get(), + ) + } single { RecommendationRepository(get()) } single { RequestsRepository(get()) } single { RequestsFeatureStore(get()) } @@ -75,6 +95,9 @@ val repositoryModule = module { single { org.prairieserver.prairie.model.feature.MetadataAiFeatureStore(get()) } single { org.prairieserver.prairie.repository.HomeRealtimeCoordinator(get(), get()) } single { SettingsRepository(get()) } + // Profile-scoped canonical settings, shared by the phone and TV screens so + // one platform cannot grow a behavior the other lacks. + single { org.prairieserver.prairie.domain.settings.ProfileSettingsController(get()) } single { LibraryPlaybackPrefsRepository(get()) } single { DownloadsRepository(get(), getOrNull() ?: org.prairieserver.prairie.repository.port.NoOpDownloadDeletionPort) } single { EbookReaderRepository(get()) } @@ -115,6 +138,7 @@ val repositoryModule = module { }, ) } + single { get() } // Eager so the identity-transition privacy gate is installed before any // profile/server/token mutation can occur. This process-lifetime scope, // rather than a screen scope, owns connection replacement and teardown. diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/settings/ProfileSettingsController.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/settings/ProfileSettingsController.kt new file mode 100644 index 000000000..e465787a5 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/settings/ProfileSettingsController.kt @@ -0,0 +1,227 @@ +package org.prairieserver.prairie.domain.settings + +import org.prairieserver.prairie.model.settings.EffectiveSettingValue +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.api.SettingsCapabilitiesResult +import org.prairieserver.prairie.repository.SettingsRepository +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive + +/** + * The profile-scoped half of the settings screens, shared by the phone and TV + * apps. + * + * These preferences used to ride `PUT /profiles/{id}` as named columns. They + * are canonical settings now: reads come from the batched effective endpoint + * (so a value set from another device, or narrowed by policy, is what the + * screen shows) and writes address `scope=profile` explicitly. Android no + * longer depends on the profile endpoint accepting `subtitle_language`, + * `subtitle_mode`, `show_forced_subtitles` or `preferred_metadata_language`, + * even though the server still mirrors them until the cutover. + * + * Both apps route through this one class deliberately: the two settings + * screens have drifted apart before, and a behavior that lives here cannot be + * present on one and missing on the other. + */ +class ProfileSettingsController( + private val repository: SettingsRepository, +) { + + /** + * Whether the connected server can serve canonical settings at all. + * [ServerUpgradeRequired] is not an error to swallow — the screen says so, + * and playback continues on local defaults. + */ + enum class Availability { + /** Not probed yet. */ + UNKNOWN, + AVAILABLE, + /** The server predates the canonical settings API (404 on the contract). */ + SERVER_UPGRADE_REQUIRED, + /** Reachable server, failed probe — transient, retryable. */ + UNAVAILABLE, + } + + /** The resolved profile preferences a settings screen renders. */ + data class Snapshot( + /** BCP 47 tag; "" is "no subtitle preference". */ + val subtitleLanguage: String = "", + /** One of "auto", "always", "off". */ + val subtitleMode: String = DEFAULT_SUBTITLE_MODE, + val showForcedSubtitles: Boolean = true, + /** BCP 47 tag; "" inherits the library metadata language. */ + val metadataLanguage: String = "", + val audioLanguageSuggestions: List = emptyList(), + val subtitleLanguageSuggestions: List = emptyList(), + val metadataLanguageSuggestions: List = emptyList(), + ) + + /** Probe result plus the values, so a screen loads both in one call. */ + data class LoadResult( + val availability: Availability, + val snapshot: Snapshot?, + ) + + /** + * The outcome of one setter: whether the write landed, and what the server + * resolves for these keys now. + * + * [snapshot] is what the screen should render — it may differ from what the + * user just chose when policy narrowed the value or a device-scoped row + * shadows the profile one. Null means the write landed but the re-resolve + * did not; the caller keeps its optimistic value rather than rolling back a + * change that did take effect. + */ + data class WriteResult( + val succeeded: Boolean, + val snapshot: Snapshot?, + ) + + /** + * Probes the contract and, when the server speaks it, resolves the profile + * keys. A failed *probe* leaves the snapshot null so the caller keeps + * whatever it had; a successful probe with a failed resolve is reported as + * [Availability.UNAVAILABLE] for the same reason. + */ + suspend fun load(): LoadResult { + val availability = when (repository.contractCapabilities()) { + is SettingsCapabilitiesResult.Available -> Availability.AVAILABLE + is SettingsCapabilitiesResult.ServerUpgradeRequired -> + Availability.SERVER_UPGRADE_REQUIRED + is SettingsCapabilitiesResult.Error, + is SettingsCapabilitiesResult.NetworkError -> Availability.UNAVAILABLE + } + if (availability != Availability.AVAILABLE) return LoadResult(availability, null) + + return when (val result = repository.getEffectiveValues(PROFILE_KEYS)) { + is ApiResult.Success -> LoadResult(availability, snapshotOf(result.data)) + is ApiResult.Error, is ApiResult.NetworkError -> + LoadResult(Availability.UNAVAILABLE, null) + } + } + + /** + * Re-resolves every profile key after a successful write. + * + * A stored value is not necessarily the effective one. Policy can narrow + * or lock a setting (`playback.preferred_quality` carries a `ceiling` + * today, and the response type has carried `constrained`/`stored_value` + * since the contract landed), and a `profile_device` row for the same key + * outranks the `profile` row these setters write — the resolver answers + * with the device id attached. In both cases the PUT succeeds and changes + * nothing the user can see, so keeping the optimistic value would leave + * the screen asserting a preference playback is not using. + * + * Returns null when the re-resolve itself fails, which is not an error the + * caller should surface: the write landed, and the optimistic value is + * still the best guess until the next load. + */ + private suspend fun reresolve(): Snapshot? = + when (val result = repository.getEffectiveValues(PROFILE_KEYS)) { + is ApiResult.Success -> snapshotOf(result.data) + is ApiResult.Error, is ApiResult.NetworkError -> null + } + + /** [language] is a BCP 47 tag, or "" for no preference. */ + suspend fun setSubtitleLanguage(language: String): WriteResult = + resolved(writeLanguage(SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, language)) + + /** [mode] is a `playback.subtitle_mode` member: "auto", "always" or "off". */ + suspend fun setSubtitleMode(mode: String): WriteResult = + resolved(write(SettingKeys.PLAYBACK_SUBTITLE_MODE, JsonPrimitive(normalizeSubtitleMode(mode)))) + + suspend fun setShowForcedSubtitles(enabled: Boolean): WriteResult = + resolved(write(SettingKeys.PLAYBACK_SHOW_FORCED_SUBTITLES, JsonPrimitive(enabled))) + + /** [language] is a BCP 47 tag, or "" to inherit the library's language. */ + suspend fun setMetadataLanguage(language: String): WriteResult = + resolved(writeLanguage(SettingKeys.CATALOG_METADATA_LANGUAGE, language)) + + private suspend fun resolved(write: ApiResult): WriteResult = + if (write is ApiResult.Success) { + WriteResult(succeeded = true, snapshot = reresolve()) + } else { + WriteResult(succeeded = false, snapshot = null) + } + + private suspend fun writeLanguage(key: String, language: String): ApiResult { + val tag = language.trim() + // The store spells "no preference" as the empty string; the contract + // spells it as no row at all (the server's language_tag validator + // refuses ""). Clearing rather than writing null keeps the two the + // same statement and matches how the server mirrors the legacy column. + return if (tag.isEmpty()) repository.clearProfileValue(key) else write(key, JsonPrimitive(tag)) + } + + private suspend fun write(key: String, value: JsonElement): ApiResult = + when (val result = repository.setProfileValue(key, value)) { + is ApiResult.Success -> ApiResult.Success(Unit) + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + + private fun snapshotOf(effective: Map): Snapshot = + Snapshot( + subtitleLanguage = effective.stringOrEmpty(SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE), + subtitleMode = normalizeSubtitleMode( + effective.stringOrEmpty(SettingKeys.PLAYBACK_SUBTITLE_MODE), + ), + showForcedSubtitles = effective.boolOr(SettingKeys.PLAYBACK_SHOW_FORCED_SUBTITLES, true), + metadataLanguage = effective.stringOrEmpty(SettingKeys.CATALOG_METADATA_LANGUAGE), + audioLanguageSuggestions = + effective[SettingKeys.PLAYBACK_AUDIO_LANGUAGE]?.suggestedValues.orEmpty(), + subtitleLanguageSuggestions = + effective[SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE]?.suggestedValues.orEmpty(), + metadataLanguageSuggestions = + effective[SettingKeys.CATALOG_METADATA_LANGUAGE]?.suggestedValues.orEmpty(), + ) + + private companion object { + const val DEFAULT_SUBTITLE_MODE = "auto" + val SUBTITLE_MODES = setOf("auto", "always", "off") + + /** + * Every profile-scoped key these screens read, in one round trip. + * + * Quality is deliberately absent: on Android the two quality axes are + * device-scoped (the store owns them and "Reset Playback Overrides" + * clears them), so a profile-scope write would be shadowed by this + * device's own row and the picker would appear not to save. + */ + val PROFILE_KEYS: List = listOf( + SettingKeys.PLAYBACK_AUDIO_LANGUAGE, + SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, + SettingKeys.PLAYBACK_SUBTITLE_MODE, + SettingKeys.PLAYBACK_SHOW_FORCED_SUBTITLES, + SettingKeys.CATALOG_METADATA_LANGUAGE, + ) + + fun normalizeSubtitleMode(value: String): String { + val v = value.trim().lowercase() + // The legacy empty string means unset, not a fourth mode. + return if (v in SUBTITLE_MODES) v else DEFAULT_SUBTITLE_MODE + } + + fun Map.stringOrEmpty(key: String): String { + val value = this[key]?.value ?: return "" + if (value is JsonNull) return "" + return runCatching { value.jsonPrimitive.content }.getOrDefault("") + } + + fun Map.boolOr(key: String, fallback: Boolean): Boolean { + val value = this[key]?.value ?: return fallback + return runCatching { value.jsonPrimitive.booleanOrNull }.getOrNull() ?: fallback + } + + fun Map.intOrNull(key: String): Int? { + val value = this[key]?.value ?: return null + if (value is JsonNull) return null + return runCatching { value.jsonPrimitive.intOrNull }.getOrNull() + } + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/InvitationModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/InvitationModels.kt new file mode 100644 index 000000000..c33d8d08e --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/InvitationModels.kt @@ -0,0 +1,21 @@ +package org.prairieserver.prairie.model.auth + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Emailed-invitation claim flow. The invitee's email address is their + * username; the claim screen asks for a password and nothing else. + */ +@Serializable +data class InvitationLookupResponse( + val email: String, + @SerialName("inviter_name") val inviterName: String? = null, + @SerialName("server_name") val serverName: String, + @SerialName("expires_at") val expiresAt: String, +) + +@Serializable +data class AcceptInvitationRequest( + val password: String, +) 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..d4d113bdd 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 @@ -372,14 +372,47 @@ data class SeasonsResponse( val seasons: List = emptyList() ) +fun Season.isSpecialsForDisplay(): Boolean = + isSpecials || seasonNumber == 0 + fun List.sortedForDisplay(): List = sortedWith( - compareBy { if (it.isSpecials) 1 else 0 } + compareByDescending { it.isSpecialsForDisplay() } .thenBy { it.seasonNumber } .thenBy { it.title.orEmpty() } .thenBy { it.contentId }, ) +private fun List.selectedSeasonForDisplay(preferredSeasonNumber: Int?): Season? { + return preferredSeasonNumber + ?.let { preferred -> firstOrNull { it.seasonNumber == preferred } } + ?: firstOrNull { !it.isSpecialsForDisplay() } + ?: firstOrNull() +} + +fun List.initialSeasonForDisplay(preferredSeasonNumber: Int?): Season? = + sortedForDisplay().selectedSeasonForDisplay(preferredSeasonNumber) + +data class InitialSeasonDisplayPlan( + val seasons: List, + val selectedSeasonNumber: Int?, +) { + val episodeRequestSeasonNumber: Int? + get() = selectedSeasonNumber +} + +fun List.initialSeasonDisplayPlan( + preferredSeasonNumber: Int?, +): InitialSeasonDisplayPlan { + val seasons = sortedForDisplay() + return InitialSeasonDisplayPlan( + seasons = seasons, + selectedSeasonNumber = seasons + .selectedSeasonForDisplay(preferredSeasonNumber) + ?.seasonNumber, + ) +} + @Serializable data class EpisodeListItem( @SerialName("content_id") val contentId: String, diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicy.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicy.kt index 94f96978a..5fc61e86b 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicy.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicy.kt @@ -5,4 +5,4 @@ package org.prairieserver.prairie.model.feature * appear in normal user navigation yet. Routes, repositories, and deep-link * plumbing can remain compiled while menus/actions stay hidden. */ -const val CLIENT_WATCH_TOGETHER_SURFACE_ENABLED: Boolean = true +const val CLIENT_WATCH_TOGETHER_SURFACE_ENABLED: Boolean = false diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/navigation/MediaMode.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/navigation/MediaMode.kt index 20941ae8b..98dcf37aa 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/navigation/MediaMode.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/navigation/MediaMode.kt @@ -38,7 +38,7 @@ private val videoLibraryTypes = setOf( // here did not degrade the library — it erased it: the type mapped to no // MediaMode, so the library never reached navigation, search or browse on // either platform, with nothing to indicate anything was missing. - // silo-apple hit the same thing and fixed it in #93. + // prairie-apple hit the same thing and fixed it in #93. "mixed", ) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/onboarding/OnboardingModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/onboarding/OnboardingModels.kt new file mode 100644 index 000000000..ae7981ecd --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/onboarding/OnboardingModels.kt @@ -0,0 +1,63 @@ +package org.prairieserver.prairie.model.onboarding + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Server-driven onboarding tour manifest. The server has already filtered + * out steps for disabled features and for the requested surface; the client + * renders the step kinds it knows and silently skips the rest — that skip is + * the forward-compatibility contract. + */ +@Serializable +data class OnboardingFlow( + val version: Int, + @SerialName("tour_id") val tourId: String, + val steps: List = emptyList(), +) + +@Serializable +data class OnboardingStep( + val id: String, + /** Open string on purpose: unknown kinds must be skipped, not fail decode. */ + val kind: String, + val title: String? = null, + val body: String? = null, + /** Client-side asset key; the server never sends image URLs. */ + val illustration: String? = null, + val setting: OnboardingSettingSpec? = null, +) + +@Serializable +data class OnboardingSettingSpec( + /** "profile_field" | "setting" | "device_setting" — selects the write API. */ + val target: String, + val key: String, + val control: String, + val options: List = emptyList(), + val default: String? = null, + val label: String? = null, +) + +@Serializable +data class OnboardingSettingOption( + val value: String, + val label: String, +) + +@Serializable +data class OnboardingState( + @SerialName("tour_id") val tourId: String, + @SerialName("last_step") val lastStep: String? = null, + @SerialName("completed_at") val completedAt: String? = null, + @SerialName("skipped_at") val skippedAt: String? = null, + val done: Boolean = false, +) + +@Serializable +data class OnboardingProgressRequest( + @SerialName("tour_id") val tourId: String, + @SerialName("last_step") val lastStep: String? = null, + val completed: Boolean = false, + val skipped: Boolean = false, +) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/profile/ProfileModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/profile/ProfileModels.kt index 5e99a11da..979ee4961 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/profile/ProfileModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/profile/ProfileModels.kt @@ -45,7 +45,6 @@ data class CreateProfileRequest( val pin: String? = null, @SerialName("is_child") val isChild: Boolean? = null, @SerialName("max_content_rating") val maxContentRating: String? = null, - @SerialName("quality_preference") val qualityPreference: String? = null, val language: String? = null, @SerialName("subtitle_language") val subtitleLanguage: String? = null, @SerialName("preferred_metadata_language") val preferredMetadataLanguage: String? = null, diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/LanguageOptions.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/LanguageOptions.kt new file mode 100644 index 000000000..84d9290c8 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/LanguageOptions.kt @@ -0,0 +1,101 @@ +package org.prairieserver.prairie.model.settings + +import org.prairieserver.prairie.playback.canonicalSubtitleLanguage + +/** Platform CLDR/ICU display name for a valid BCP 47 tag. */ +internal expect fun localizedLanguageName(tag: String): String + +/** Alias-aware identity that retains explicit script and region subtags. */ +internal expect fun canonicalLanguageIdentity(tag: String): String + +/** + * Presentation adapter for the generated settings language catalogs. + * + * `language_tag` stays open: the generated list is a stable floor, the server + * may add deployment-observed tags, and an exact current value is always kept + * selectable. Labels come from the platform locale rather than a hand-written + * English table that can drift between phone, TV, web, and Apple clients. + */ +object LanguageOptions { + /** Wire value meaning "no preference"; the server stores this as null. */ + const val UNSET = "" + + fun namedOptions( + key: String, + currentValue: String? = null, + runtimeValues: List = emptyList(), + ): List> { + val values = mutableListOf() + val indexByLanguage = mutableMapOf() + + fun add(rawValue: String, replaceAlias: Boolean) { + val value = rawValue.trim() + if (!isPreservableTag(value)) return + val identity = canonicalLanguageIdentity(value) + val existing = indexByLanguage[identity] + if (existing != null) { + if (replaceAlias) values[existing] = value + return + } + indexByLanguage[identity] = values.size + values += value + } + + SettingPresentationMetadata.suggestedValues(key).forEach { add(it, false) } + runtimeValues.forEach { add(it, false) } + currentValue?.let { add(it, true) } + + return values.map { it to localizedLanguageName(it) } + } + + /** Full picker list led by the contract's context-specific unset copy. */ + fun options( + key: String, + currentValue: String? = null, + runtimeValues: List = emptyList(), + ): List> = + listOf(UNSET to unsetLabel(key)) + namedOptions(key, currentValue, runtimeValues) + + fun label(wire: String?, key: String): String { + if (wire.isNullOrBlank()) return unsetLabel(key) + return if (isPreservableTag(wire)) localizedLanguageName(wire) else unsetLabel(key) + } + + /** Resolve a selected display label against the exact options rendered. */ + fun wireValue(label: String?, options: List>): String = + options.firstOrNull { it.second == label }?.first ?: UNSET + + /** + * Translates values written by older builds that persisted English labels. + * This compatibility map is not a picker catalog; new choices come only + * from the generated contract and server response. + */ + fun migrateLegacyValue(stored: String?): String = when { + stored.isNullOrBlank() -> UNSET + isPreservableTag(stored) -> stored + else -> legacyEnglishLabels[stored] ?: UNSET + } + + private fun unsetLabel(key: String): String = + SettingPresentationMetadata.DEFINITIONS[key]?.unsetLabel ?: "Unset" + + private fun isPreservableTag(value: String): Boolean = + value.isNotBlank() && + !value.equals("Off", ignoreCase = true) && + !value.equals("Default", ignoreCase = true) && + Regex("^[a-zA-Z]{2,3}([-_][a-zA-Z0-9]{1,8})*$").matches(value) && + canonicalSubtitleLanguage(value) != null + + private val legacyEnglishLabels = mapOf( + "English" to "en", + "Spanish" to "es", + "French" to "fr", + "German" to "de", + "Japanese" to "ja", + "Korean" to "ko", + "Chinese" to "zh", + "Portuguese" to "pt", + "Italian" to "it", + "Russian" to "ru", + ) +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/PlaybackSettingsKeys.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/PlaybackSettingsKeys.kt index e255adc65..43279c8b2 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/PlaybackSettingsKeys.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/PlaybackSettingsKeys.kt @@ -2,26 +2,36 @@ package org.prairieserver.prairie.model.settings object PlaybackSettingsKeys { const val PreferredQuality = "playback.preferred_quality" + + /** + * The bandwidth half of the quality choice, orthogonal to + * [PreferredQuality]. Nullable on the wire — uncapped is the *absence* of + * a value, not a large number — which the local store spells as 0, the one + * value the contract's 100..200000 range cannot hold. + */ + const val MaxBitrateKbps = "playback.max_bitrate_kbps" const val AudioLanguage = "playback.audio_language" const val AutoSkipIntro = "playback.auto_skip_intro" const val AutoSkipCredits = "playback.auto_skip_credits" const val AutoPlayNext = "playback.auto_play_next" - const val SubtitleAppearance = "subtitle_appearance" + // Renamed at the settings cutover: every other key carries a domain prefix + // and this one did not, so the contract registers it as + // playback.subtitle_appearance. Old servers are gone by the time this + // ships, so there is no dual-write to the server — but the local slot an + // installed build already wrote is still on disk under the old spelling, + // so [RenamedLocalKeys] copies it forward once. + const val SubtitleAppearance = "playback.subtitle_appearance" const val HdrEnabled = "player.hdr_enabled" const val PlaybackSpeed = "player.playback_speed" const val AudioSyncMs = "player.audio_sync_ms" const val SubtitleSyncMs = "player.subtitle_sync_ms" - - /** - * Per-item subtitle sync overrides, encoded as `contentId=ms` pairs. - * Deliberately absent from [DeviceSettings]: it is local-only, because the - * server has no schema for it and an unknown key would poison a settings - * flush batch. - */ - const val SubtitleSyncMsByItem = "player.subtitle_sync_ms_by_item" const val VideoGravity = "player.video_gravity" const val OrientationMode = "player.orientation_mode" - const val NextUpPromptSeconds = "player.next_up_prompt_seconds" + // Android shipped this under player.* while Apple and the server used + // playback.*, so the same preference was two settings and neither client + // could read the other's. The contract settles on playback.*, and + // [RenamedLocalKeys] carries the already-written local value across. + const val NextUpPromptSeconds = "playback.next_up_prompt_seconds" const val DvProfile7HDR10Fallback = "player.dv_profile7_hdr10_fallback" const val DolbyVisionEnabled = "player.dolby_vision_enabled" const val MatchContentFrameRate = "player.match_frame_rate" @@ -98,6 +108,7 @@ object PlaybackSettingsKeys { val DeviceSettings = listOf( PreferredQuality, + MaxBitrateKbps, AudioLanguage, AutoSkipIntro, AutoSkipCredits, @@ -124,4 +135,22 @@ object PlaybackSettingsKeys { SubtitleTextOutlineColor, SubtitlePosition, ) + + /** + * `old local slot -> current key`, for the two keys the settings cutover + * renamed. + * + * The rename is only a contract question for the *server*; on disk it + * orphans a value the user already set. Both keys read local-first — + * subtitle appearance drives downloaded playback with no server in the + * loop at all, and next-up prompt seconds falls back to its 30s default — + * so without this copy an upgrade silently reverts both until (and unless) + * a canonical refresh succeeds. The copy runs once, inside the same + * sentinel-gated migration that imports the legacy cache, and never + * overwrites a value already present under the new name. + */ + val RenamedLocalKeys: Map = mapOf( + "subtitle_appearance" to SubtitleAppearance, + "player.next_up_prompt_seconds" to NextUpPromptSeconds, + ) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/QualityPresets.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/QualityPresets.kt new file mode 100644 index 000000000..018ea66e6 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/QualityPresets.kt @@ -0,0 +1,160 @@ +package org.prairieserver.prairie.model.settings + +/** + * The quality picker's presets. + * + * The server stores two orthogonal values — `playback.preferred_quality` (a + * resolution cap) and `playback.max_bitrate_kbps` (a bandwidth cap, null for + * uncapped). This table composes them into the single list a user picks from, + * and is a field-for-field port of the web client's `qualityPresets.ts` so a + * preset chosen on one platform reads back with the same label on the others. + * + * Presets live here rather than in the contract on purpose. Baking "high" into + * an enum member would freeze what it means: retuning 1080p High from 10 to 12 + * Mbps would be a contract change every client has to agree to. As a + * client-side table it is a one-line edit, and older servers keep working + * because they only ever see the two axes they already understand. + * + * The compound legacy spellings (`1080p-high`, `720p-high`, …) were never a + * third dimension, only a bitrate written into the resolution string. They are + * dead: nothing here produces one, and [presetFor] decomposes any that a + * pre-contract local value still holds. + */ +data class QualityPreset( + val id: String, + val label: String, + val description: String, + /** A `playback.preferred_quality` enum member. */ + val resolution: String, + /** null is uncapped — the absence of a `playback.max_bitrate_kbps` row. */ + val bitrateKbps: Int?, +) + +object QualityPresets { + + const val RESOLUTION_AUTO = "auto" + const val RESOLUTION_ORIGINAL = "original" + + val ALL: List = listOf( + QualityPreset( + id = "auto", + label = "Auto", + description = "Prairie picks based on your connection.", + resolution = RESOLUTION_AUTO, + bitrateKbps = null, + ), + QualityPreset( + id = "original", + label = "Original", + description = "Never transcode. Needs bandwidth to match the file.", + resolution = RESOLUTION_ORIGINAL, + bitrateKbps = null, + ), + QualityPreset( + id = "2160p", + label = "4K", + description = "Up to 2160p.", + resolution = "2160p", + bitrateKbps = null, + ), + QualityPreset( + id = "1080p-high", + label = "1080p High", + description = "1080p at up to 10 Mbps.", + resolution = "1080p", + bitrateKbps = 10000, + ), + QualityPreset( + id = "1080p", + label = "1080p", + description = "1080p at up to 6 Mbps.", + resolution = "1080p", + bitrateKbps = 6000, + ), + QualityPreset( + id = "1080p-low", + label = "1080p Low", + description = "1080p at up to 3 Mbps, for a slower link.", + resolution = "1080p", + bitrateKbps = 3000, + ), + QualityPreset( + id = "720p-high", + label = "720p High", + description = "720p at up to 4 Mbps.", + resolution = "720p", + bitrateKbps = 4000, + ), + QualityPreset( + id = "720p", + label = "720p", + description = "720p at up to 2 Mbps.", + resolution = "720p", + bitrateKbps = 2000, + ), + QualityPreset( + id = "480p", + label = "480p", + description = "480p at up to 1.5 Mbps, for the tightest connections.", + resolution = "480p", + bitrateKbps = 1500, + ), + ) + + /** The preset for a stored (resolution, bitrate) pair, or null for a combination no preset covers. */ + fun presetFor(resolution: String?, bitrateKbps: Int?): QualityPreset? { + val normalizedResolution = normalizeResolution(resolution) + val normalizedBitrate = bitrateKbps?.takeIf { it > 0 } + return ALL.firstOrNull { + it.resolution == normalizedResolution && it.bitrateKbps == normalizedBitrate + } + } + + fun byId(id: String?): QualityPreset? = ALL.firstOrNull { it.id == id } + + /** + * A label for any stored pair, including combinations no preset covers — + * someone who set the two axes independently through the API, or whose + * values came from a legacy compound value. + */ + fun describe(resolution: String?, bitrateKbps: Int?): String { + presetFor(resolution, bitrateKbps)?.let { return it.label } + + val normalized = normalizeResolution(resolution) + val resolutionLabel = when (normalized) { + RESOLUTION_AUTO -> "Auto" + RESOLUTION_ORIGINAL -> "Original" + "2160p" -> "4K" + else -> normalized + } + val capped = bitrateKbps?.takeIf { it > 0 } ?: return resolutionLabel + val mbps = capped / 1000.0 + val rounded = if (capped % 1000 == 0) "${capped / 1000}" else formatOneDecimal(mbps) + return "$resolutionLabel at $rounded Mbps" + } + + /** + * Reduces any stored resolution — including the compound transcode-ladder + * spellings older builds wrote (`1080p-high`, `720p-8`, `4k`) — to a member + * of the contract's enum. The bitrate half of a compound value is dropped + * rather than guessed at: the bitrate axis carries it now, and inventing a + * cap the user never chose would silently throttle playback. + */ + fun normalizeResolution(value: String?): String { + val trimmed = value?.trim()?.lowercase().orEmpty() + if (trimmed.isEmpty()) return RESOLUTION_AUTO + if (trimmed == RESOLUTION_AUTO || trimmed == RESOLUTION_ORIGINAL) return trimmed + if (trimmed == "4k") return "2160p" + val head = trimmed.substringBefore('-') + return when (head) { + "480p", "720p", "1080p", "2160p" -> head + "4k" -> "2160p" + else -> RESOLUTION_AUTO + } + } + + private fun formatOneDecimal(value: Double): String { + val tenths = kotlin.math.round(value * 10).toInt() + return "${tenths / 10}.${tenths % 10}" + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SettingKeys.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SettingKeys.kt new file mode 100644 index 000000000..7109c8269 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SettingKeys.kt @@ -0,0 +1,366 @@ +// Code generated by cmd/settingsgen from contracts/settings/v1/manifest.json. DO NOT EDIT. +// +// Regenerate with: make settings-bindings +// +// Every key, type, scope and default here comes from the manifest, so a client +// cannot drift from the server's contract by editing a constant. Adding a setting +// is a manifest change plus a regeneration, never a hand-written key. + +package org.prairieserver.prairie.model.settings + +data class SettingSuggestedOption( + val value: String, + val introducedIn: Int, +) + +data class SettingOptionSet( + val type: String, + val options: List, +) + +data class SettingPresentation( + val suggestedOptions: String? = null, + val unsetLabel: String? = null, +) + +object SettingKeys { + const val REVISION = 2 + + /** Metadata language */ + const val CATALOG_METADATA_LANGUAGE = "catalog.metadata_language" + /** Download quality */ + const val DOWNLOADS_DEFAULT_QUALITY = "downloads.default_quality" + /** Keep watched downloads */ + const val DOWNLOADS_KEEP_WATCHED = "downloads.keep_watched" + /** Download over Wi-Fi only */ + const val DOWNLOADS_WIFI_ONLY = "downloads.wifi_only" + /** Show audiobooks */ + const val NAV_SHOW_AUDIOBOOKS = "nav.show_audiobooks" + /** Preferred audio language */ + const val PLAYBACK_AUDIO_LANGUAGE = "playback.audio_language" + /** Auto-play next episode */ + const val PLAYBACK_AUTO_PLAY_NEXT = "playback.auto_play_next" + /** Preview next episode */ + const val PLAYBACK_AUTO_PLAY_NEXT_PREVIEW = "playback.auto_play_next_preview" + /** Auto-skip credits */ + const val PLAYBACK_AUTO_SKIP_CREDITS = "playback.auto_skip_credits" + /** Auto-skip intros */ + const val PLAYBACK_AUTO_SKIP_INTRO = "playback.auto_skip_intro" + /** Auto-skip recaps */ + const val PLAYBACK_AUTO_SKIP_RECAP = "playback.auto_skip_recap" + /** Maximum bitrate */ + const val PLAYBACK_MAX_BITRATE_KBPS = "playback.max_bitrate_kbps" + /** Next up prompt */ + const val PLAYBACK_NEXT_UP_PROMPT_SECONDS = "playback.next_up_prompt_seconds" + /** Preferred quality */ + const val PLAYBACK_PREFERRED_QUALITY = "playback.preferred_quality" + /** Show forced subtitles */ + const val PLAYBACK_SHOW_FORCED_SUBTITLES = "playback.show_forced_subtitles" + /** Subtitle appearance */ + const val PLAYBACK_SUBTITLE_APPEARANCE = "playback.subtitle_appearance" + /** Preferred subtitle language */ + const val PLAYBACK_SUBTITLE_LANGUAGE = "playback.subtitle_language" + /** Subtitles */ + const val PLAYBACK_SUBTITLE_MODE = "playback.subtitle_mode" + /** Audio sync offset */ + const val PLAYER_AUDIO_SYNC_MS = "player.audio_sync_ms" + /** Dolby Vision */ + const val PLAYER_DOLBY_VISION_ENABLED = "player.dolby_vision_enabled" + /** Dolby Vision Profile 7 fallback */ + const val PLAYER_DV_PROFILE7_HDR10_FALLBACK = "player.dv_profile7_hdr10_fallback" + /** HDR */ + const val PLAYER_HDR_ENABLED = "player.hdr_enabled" + /** Match content frame rate */ + const val PLAYER_MATCH_FRAME_RATE = "player.match_frame_rate" + /** Screen orientation */ + const val PLAYER_ORIENTATION_MODE = "player.orientation_mode" + /** Still watching prompt */ + const val PLAYER_PASSOUT_THRESHOLD = "player.passout_threshold" + /** Picture in picture */ + const val PLAYER_PICTURE_IN_PICTURE_ENABLED = "player.picture_in_picture_enabled" + /** Playback speed */ + const val PLAYER_PLAYBACK_SPEED = "player.playback_speed" + /** Rewind on resume */ + const val PLAYER_RESUME_REWIND_SECONDS = "player.resume_rewind_seconds" + /** Seek cache */ + const val PLAYER_SEEK_CACHE_ENABLED = "player.seek_cache_enabled" + /** Default sleep timer */ + const val PLAYER_SLEEP_TIMER_DEFAULT_MINUTES = "player.sleep_timer_default_minutes" + /** Subtitle sync offset */ + const val PLAYER_SUBTITLE_SYNC_MS = "player.subtitle_sync_ms" + /** Video sizing */ + const val PLAYER_VIDEO_GRAVITY = "player.video_gravity" + /** Search scope */ + const val SEARCH_MEDIA_SCOPE = "search.media_scope" + /** Match device caption settings */ + const val SUBTITLE_MATCHES_DEVICE = "subtitle.matches_device" + /** Poster badges */ + const val UI_CARD_OVERLAYS = "ui.card_overlays" + /** Custom CSS */ + const val UI_CUSTOM_CSS = "ui.custom_css" + /** Custom theme variables */ + const val UI_CUSTOM_THEME_VARS = "ui.custom_theme_vars" + /** Date format */ + const val UI_DATE_FORMAT = "ui.date_format" + /** Hidden libraries */ + const val UI_DISABLED_LIBRARY_IDS = "ui.disabled_library_ids" + /** High contrast */ + const val UI_HIGH_CONTRAST = "ui.high_contrast" + /** Library order */ + const val UI_LIBRARY_ORDER = "ui.library_order" + /** Remembered library view */ + const val UI_LIBRARY_PAGE_STATE = "ui.library_page_state" + /** Next up episodes */ + const val UI_NEXT_UP_MODE = "ui.next_up_mode" + /** Remember library view */ + const val UI_REMEMBER_LIBRARY_PAGE_STATE = "ui.remember_library_page_state" + /** Pinned sidebar items */ + const val UI_SIDEBAR_PINS = "ui.sidebar_pins" + /** Text size */ + const val UI_TEXT_SCALE = "ui.text_scale" + /** Text weight */ + const val UI_TEXT_WEIGHT = "ui.text_weight" + /** Theme */ + const val UI_THEME = "ui.theme" + /** Time format */ + const val UI_TIME_FORMAT = "ui.time_format" + + /** Every key the server stores. Safe to flush. */ + val REMOTE: List = listOf( + CATALOG_METADATA_LANGUAGE, + PLAYBACK_AUDIO_LANGUAGE, + PLAYBACK_AUTO_PLAY_NEXT, + PLAYBACK_AUTO_PLAY_NEXT_PREVIEW, + PLAYBACK_AUTO_SKIP_CREDITS, + PLAYBACK_AUTO_SKIP_INTRO, + PLAYBACK_AUTO_SKIP_RECAP, + PLAYBACK_MAX_BITRATE_KBPS, + PLAYBACK_NEXT_UP_PROMPT_SECONDS, + PLAYBACK_PREFERRED_QUALITY, + PLAYBACK_SHOW_FORCED_SUBTITLES, + PLAYBACK_SUBTITLE_APPEARANCE, + PLAYBACK_SUBTITLE_LANGUAGE, + PLAYBACK_SUBTITLE_MODE, + PLAYER_AUDIO_SYNC_MS, + PLAYER_DOLBY_VISION_ENABLED, + PLAYER_DV_PROFILE7_HDR10_FALLBACK, + PLAYER_HDR_ENABLED, + PLAYER_MATCH_FRAME_RATE, + PLAYER_ORIENTATION_MODE, + PLAYER_PLAYBACK_SPEED, + PLAYER_SEEK_CACHE_ENABLED, + PLAYER_SLEEP_TIMER_DEFAULT_MINUTES, + PLAYER_SUBTITLE_SYNC_MS, + PLAYER_VIDEO_GRAVITY, + SEARCH_MEDIA_SCOPE, + UI_CARD_OVERLAYS, + UI_CUSTOM_CSS, + UI_CUSTOM_THEME_VARS, + UI_DATE_FORMAT, + UI_DISABLED_LIBRARY_IDS, + UI_HIGH_CONTRAST, + UI_LIBRARY_ORDER, + UI_LIBRARY_PAGE_STATE, + UI_NEXT_UP_MODE, + UI_REMEMBER_LIBRARY_PAGE_STATE, + UI_SIDEBAR_PINS, + UI_TEXT_SCALE, + UI_TEXT_WEIGHT, + UI_THEME, + UI_TIME_FORMAT, + ) + + /** Contract-known keys that never leave the device. */ + val CLIENT_LOCAL: List = listOf( + DOWNLOADS_DEFAULT_QUALITY, + DOWNLOADS_KEEP_WATCHED, + DOWNLOADS_WIFI_ONLY, + NAV_SHOW_AUDIOBOOKS, + PLAYER_PASSOUT_THRESHOLD, + PLAYER_PICTURE_IN_PICTURE_ENABLED, + PLAYER_RESUME_REWIND_SECONDS, + SUBTITLE_MATCHES_DEVICE, + ) + + val BOOLEAN_KEYS: Set = setOf( + PLAYBACK_AUTO_PLAY_NEXT, + PLAYBACK_AUTO_PLAY_NEXT_PREVIEW, + PLAYBACK_AUTO_SKIP_CREDITS, + PLAYBACK_AUTO_SKIP_INTRO, + PLAYBACK_AUTO_SKIP_RECAP, + PLAYBACK_SHOW_FORCED_SUBTITLES, + PLAYER_DOLBY_VISION_ENABLED, + PLAYER_DV_PROFILE7_HDR10_FALLBACK, + PLAYER_HDR_ENABLED, + PLAYER_MATCH_FRAME_RATE, + PLAYER_SEEK_CACHE_ENABLED, + UI_HIGH_CONTRAST, + UI_REMEMBER_LIBRARY_PAGE_STATE, + ) + + val INT_KEYS: Set = setOf( + PLAYBACK_MAX_BITRATE_KBPS, + PLAYBACK_NEXT_UP_PROMPT_SECONDS, + PLAYER_AUDIO_SYNC_MS, + PLAYER_SLEEP_TIMER_DEFAULT_MINUTES, + PLAYER_SUBTITLE_SYNC_MS, + ) + + val DOUBLE_KEYS: Set = setOf( + PLAYER_PLAYBACK_SPEED, + ) +} + +object SettingPresentationMetadata { + val OPTION_SETS: Map = mapOf( + "catalog_metadata_languages" to SettingOptionSet( + type = "language_tag", + options = listOf( + SettingSuggestedOption("ar", 1), + SettingSuggestedOption("bn", 1), + SettingSuggestedOption("bg", 1), + SettingSuggestedOption("zh", 1), + SettingSuggestedOption("hr", 1), + SettingSuggestedOption("cs", 1), + SettingSuggestedOption("da", 1), + SettingSuggestedOption("nl", 1), + SettingSuggestedOption("en", 1), + SettingSuggestedOption("fi", 1), + SettingSuggestedOption("fr", 1), + SettingSuggestedOption("de", 1), + SettingSuggestedOption("el", 1), + SettingSuggestedOption("he", 1), + SettingSuggestedOption("hi", 1), + SettingSuggestedOption("hu", 1), + SettingSuggestedOption("id", 1), + SettingSuggestedOption("it", 1), + SettingSuggestedOption("ja", 1), + SettingSuggestedOption("ko", 1), + SettingSuggestedOption("ms", 1), + SettingSuggestedOption("no", 1), + SettingSuggestedOption("fa", 1), + SettingSuggestedOption("pl", 1), + SettingSuggestedOption("pt", 1), + SettingSuggestedOption("ro", 1), + SettingSuggestedOption("ru", 1), + SettingSuggestedOption("sk", 1), + SettingSuggestedOption("sl", 1), + SettingSuggestedOption("es", 1), + SettingSuggestedOption("sv", 1), + SettingSuggestedOption("ta", 1), + SettingSuggestedOption("te", 1), + SettingSuggestedOption("th", 1), + SettingSuggestedOption("tr", 1), + SettingSuggestedOption("uk", 1), + SettingSuggestedOption("vi", 1), + ), + ), + "playback_audio_languages" to SettingOptionSet( + type = "language_tag", + options = listOf( + SettingSuggestedOption("ar", 1), + SettingSuggestedOption("bn", 1), + SettingSuggestedOption("bg", 1), + SettingSuggestedOption("zh", 1), + SettingSuggestedOption("hr", 1), + SettingSuggestedOption("cs", 1), + SettingSuggestedOption("da", 1), + SettingSuggestedOption("nl", 1), + SettingSuggestedOption("en", 1), + SettingSuggestedOption("fi", 1), + SettingSuggestedOption("fr", 1), + SettingSuggestedOption("de", 1), + SettingSuggestedOption("el", 1), + SettingSuggestedOption("he", 1), + SettingSuggestedOption("hi", 1), + SettingSuggestedOption("hu", 1), + SettingSuggestedOption("id", 1), + SettingSuggestedOption("it", 1), + SettingSuggestedOption("ja", 1), + SettingSuggestedOption("ko", 1), + SettingSuggestedOption("ms", 1), + SettingSuggestedOption("no", 1), + SettingSuggestedOption("fa", 1), + SettingSuggestedOption("pl", 1), + SettingSuggestedOption("pt", 1), + SettingSuggestedOption("ro", 1), + SettingSuggestedOption("ru", 1), + SettingSuggestedOption("sk", 1), + SettingSuggestedOption("sl", 1), + SettingSuggestedOption("es", 1), + SettingSuggestedOption("sv", 1), + SettingSuggestedOption("ta", 1), + SettingSuggestedOption("te", 1), + SettingSuggestedOption("th", 1), + SettingSuggestedOption("tr", 1), + SettingSuggestedOption("uk", 1), + SettingSuggestedOption("vi", 1), + ), + ), + "playback_subtitle_languages" to SettingOptionSet( + type = "language_tag", + options = listOf( + SettingSuggestedOption("ar", 1), + SettingSuggestedOption("bn", 1), + SettingSuggestedOption("bg", 1), + SettingSuggestedOption("zh", 1), + SettingSuggestedOption("hr", 1), + SettingSuggestedOption("cs", 1), + SettingSuggestedOption("da", 1), + SettingSuggestedOption("nl", 1), + SettingSuggestedOption("en", 1), + SettingSuggestedOption("fi", 1), + SettingSuggestedOption("fr", 1), + SettingSuggestedOption("de", 1), + SettingSuggestedOption("el", 1), + SettingSuggestedOption("he", 1), + SettingSuggestedOption("hi", 1), + SettingSuggestedOption("hu", 1), + SettingSuggestedOption("id", 1), + SettingSuggestedOption("it", 1), + SettingSuggestedOption("ja", 1), + SettingSuggestedOption("ko", 1), + SettingSuggestedOption("ms", 1), + SettingSuggestedOption("no", 1), + SettingSuggestedOption("fa", 1), + SettingSuggestedOption("pl", 1), + SettingSuggestedOption("pt", 1), + SettingSuggestedOption("ro", 1), + SettingSuggestedOption("ru", 1), + SettingSuggestedOption("sk", 1), + SettingSuggestedOption("sl", 1), + SettingSuggestedOption("es", 1), + SettingSuggestedOption("sv", 1), + SettingSuggestedOption("ta", 1), + SettingSuggestedOption("te", 1), + SettingSuggestedOption("th", 1), + SettingSuggestedOption("tr", 1), + SettingSuggestedOption("uk", 1), + SettingSuggestedOption("vi", 1), + ), + ), + ) + + val DEFINITIONS: Map = mapOf( + SettingKeys.CATALOG_METADATA_LANGUAGE to SettingPresentation( + suggestedOptions = "catalog_metadata_languages", + unsetLabel = "Library default", + ), + SettingKeys.PLAYBACK_AUDIO_LANGUAGE to SettingPresentation( + suggestedOptions = "playback_audio_languages", + unsetLabel = "No preference", + ), + SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE to SettingPresentation( + suggestedOptions = "playback_subtitle_languages", + unsetLabel = "None", + ), + ) + + fun suggestedValues(key: String, revision: Int = SettingKeys.REVISION): List { + val setId = DEFINITIONS[key]?.suggestedOptions ?: return emptyList() + return OPTION_SETS[setId]?.options + ?.filter { it.introducedIn <= revision } + ?.map { it.value } + .orEmpty() + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SettingValueModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SettingValueModels.kt new file mode 100644 index 000000000..962134e10 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SettingValueModels.kt @@ -0,0 +1,163 @@ +package org.prairieserver.prairie.model.settings + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull + +/** + * Wire models for the canonical settings API (`/api/v1/settings/contract` + * and the `/api/v1/settings/values` routes). + * + * These mirror the server's `settings_values.go` handler shapes exactly. The + * older models in [SettingsModels.kt] speak the legacy string-only endpoints; + * here values are typed JSON, scope is explicit, and unknown keys are refused + * by the server rather than stored. + */ + +/** + * The five scopes an explicit value can live at, in the server's wire + * spelling. Kept as an enum for request construction only — response fields + * stay raw strings so a server that adds a scope cannot break deserialization. + */ +enum class SettingScope(val wire: String) { + ACCOUNT("account"), + PROFILE("profile"), + PROFILE_DEVICE("profile_device"), + PROFILE_LIBRARY("profile_library"), + PROFILE_SERIES("profile_series"), +} + +/** + * The scope identity a write or delete addresses. + * + * Only the content ids travel with the request: `scope` plus `library_id` / + * `series_id` go in the query string. The profile and device parts of the + * identity come from the session headers (`X-Profile-Id`, `X-Prairie-Device-Id`) + * that the auth interceptor already attaches — the server reads them from + * there deliberately, so one profile cannot write another's settings by + * naming it in the query. + * + * The init block enforces the fields each scope requires — the same check the + * server's identity validation makes — so an invalid identity fails at + * construction instead of as a 400. The companion factories are the readable + * way to build one. + */ +data class SettingScopeIdentity( + val scope: SettingScope, + /** Set only for [SettingScope.PROFILE_LIBRARY]. */ + val libraryId: Int? = null, + /** Set only for [SettingScope.PROFILE_SERIES]. */ + val seriesId: String? = null, +) { + init { + require((scope == SettingScope.PROFILE_LIBRARY) == (libraryId != null)) { + "library_id is required for profile_library and forbidden elsewhere" + } + libraryId?.let { require(it > 0) { "library_id must be positive" } } + require((scope == SettingScope.PROFILE_SERIES) == (seriesId != null)) { + "series_id is required for profile_series and forbidden elsewhere" + } + seriesId?.let { require(it.isNotBlank()) { "series_id must not be blank" } } + } + + companion object { + fun account(): SettingScopeIdentity = SettingScopeIdentity(SettingScope.ACCOUNT) + + fun profile(): SettingScopeIdentity = SettingScopeIdentity(SettingScope.PROFILE) + + fun profileDevice(): SettingScopeIdentity = + SettingScopeIdentity(SettingScope.PROFILE_DEVICE) + + fun profileLibrary(libraryId: Int): SettingScopeIdentity = + SettingScopeIdentity(SettingScope.PROFILE_LIBRARY, libraryId = libraryId) + + fun profileSeries(seriesId: String): SettingScopeIdentity = + SettingScopeIdentity(SettingScope.PROFILE_SERIES, seriesId = seriesId) + } +} + +/** + * `GET /api/v1/settings/contract/capabilities` — what the connected server + * supports, for feature detection rather than version sniffing. Compare + * [revision] against the generated [SettingKeys.REVISION] to hide definitions + * the server does not know yet. + */ +@Serializable +data class SettingsContractCapabilities( + @SerialName("api_version") val apiVersion: Int = 0, + val revision: Int = 0, + @SerialName("contract_etag") val contractEtag: String = "", + @SerialName("definition_count") val definitionCount: Int = 0, + val scopes: List = emptyList(), + @SerialName("supports_batched_effective") val supportsBatchedEffective: Boolean = false, + @SerialName("supports_idempotent_writes") val supportsIdempotentWrites: Boolean = false, +) + +/** Body for `PUT /api/v1/settings/values/{key}`: `{"value": …}`. */ +@Serializable +data class SettingValueWriteRequest( + val value: JsonElement, +) + +/** + * One explicit stored value: the receipt returned by a PUT, and the shape a + * GET at one scope returns. An idempotent replay of a PUT returns the + * recorded receipt, which omits [revision] and [updatedAt] — treat them as + * informational, not as fields every response carries. + */ +@Serializable +data class StoredSettingValue( + val key: String, + val scope: String, + @SerialName("profile_id") val profileId: String? = null, + @SerialName("device_id") val deviceId: String? = null, + @SerialName("library_id") val libraryId: Int? = null, + @SerialName("series_id") val seriesId: String? = null, + val value: JsonElement = JsonNull, + val revision: Long = 0, + @SerialName("updated_at") val updatedAt: String? = null, +) + +/** + * One resolved value plus where it came from. + * + * [storedValue] and [constrained] are present only when policy narrowed the + * answer: [value] is what applies, [storedValue] is what the user chose, so a + * client can say "your choice is capped" instead of silently showing the cap. + * The scope fields locate the row the value came from, so a reset can target + * exactly that scope; they are absent for a contract default. + */ +@Serializable +data class EffectiveSettingValue( + val key: String, + val value: JsonElement = JsonNull, + val source: String = SOURCE_DEFAULT, + @SerialName("stored_value") val storedValue: JsonElement? = null, + val constrained: Boolean = false, + /** One of "ceiling", "floor", "allowlist", "locked" when [constrained]. */ + @SerialName("constraint_kind") val constraintKind: String? = null, + /** Advisory values for an open picker; never a write allowlist. */ + @SerialName("suggested_values") val suggestedValues: List = emptyList(), + val scope: String? = null, + @SerialName("profile_id") val profileId: String? = null, + @SerialName("device_id") val deviceId: String? = null, + @SerialName("library_id") val libraryId: Int? = null, + @SerialName("series_id") val seriesId: String? = null, +) { + companion object { + /** [source] when no stored value applied and the contract default won. */ + const val SOURCE_DEFAULT = "default" + } +} + +/** + * `GET /api/v1/settings/values/effective`. [revision] names the contract + * revision the resolution was computed at, so definitions, scopes and enum + * members can be filtered against it. + */ +@Serializable +data class EffectiveSettingValuesResponse( + val settings: List = emptyList(), + val revision: Int = 0, +) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearance.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearance.kt index ef01bdd07..2695fd533 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearance.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearance.kt @@ -5,13 +5,19 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json +/** + * `wire` repeats what each `@SerialName` declares. It is spelled out as a + * property so the granular, client-local `subtitle.*` slots can be parsed and + * written without a JSON round-trip — see [SubtitleAppearanceProjection] — and + * so a rename cannot change the enum's wire form on one path only. + */ @Serializable -enum class SubtitleFontSizePreset { - @SerialName("small") Small, - @SerialName("medium") Medium, - @SerialName("large") Large, - @SerialName("xlarge") XLarge, - @SerialName("xxlarge") XXLarge, +enum class SubtitleFontSizePreset(val wire: String) { + @SerialName("small") Small("small"), + @SerialName("medium") Medium("medium"), + @SerialName("large") Large("large"), + @SerialName("xlarge") XLarge("xlarge"), + @SerialName("xxlarge") XXLarge("xxlarge"), } val SubtitleFontSizePreset.pointSize: Double @@ -24,18 +30,18 @@ val SubtitleFontSizePreset.pointSize: Double } @Serializable -enum class SubtitleBackgroundStylePreset { - @SerialName("box") Box, - @SerialName("shadow") Shadow, - @SerialName("outline") Outline, - @SerialName("none") None, +enum class SubtitleBackgroundStylePreset(val wire: String) { + @SerialName("box") Box("box"), + @SerialName("shadow") Shadow("shadow"), + @SerialName("outline") Outline("outline"), + @SerialName("none") None("none"), } @Serializable -enum class SubtitlePositionPreset { - @SerialName("bottom") Bottom, - @SerialName("lower-third") LowerThird, - @SerialName("top") Top, +enum class SubtitlePositionPreset(val wire: String) { + @SerialName("bottom") Bottom("bottom"), + @SerialName("lower-third") LowerThird("lower-third"), + @SerialName("top") Top("top"), } val SubtitlePositionPreset.legacyPosition: Int diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceProjection.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceProjection.kt new file mode 100644 index 000000000..a8f6b2517 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceProjection.kt @@ -0,0 +1,121 @@ +package org.prairieserver.prairie.model.settings + +/** + * Bridge between the granular `subtitle.*` fields Android stores on the device + * and the one composite `playback.subtitle_appearance` object the contract + * carries. + * + * The contract has no definitions for the granular fields — the resolver would + * refuse them as `unknown_setting` — so they stay client-local. That does not + * make them private: a per-field edit has to reach the server, and it does so + * by being projected into the composite before the flush. [project] merges the + * granular slots over a base appearance (sparse: an absent or unparseable field + * leaves the base value alone, matching the schema's "a stored value is a + * sparse override" rule), and [flatten] writes the composite back out so the + * two representations never drift after a server refresh. + */ +object SubtitleAppearanceProjection { + + /** The granular keys, in the order [flatten] emits them. */ + val GRANULAR_KEYS: List = listOf( + PlaybackSettingsKeys.SubtitleFontSize, + PlaybackSettingsKeys.SubtitleFontFamily, + PlaybackSettingsKeys.SubtitleTextColor, + PlaybackSettingsKeys.SubtitleBackgroundColor, + PlaybackSettingsKeys.SubtitleBackgroundStyle, + PlaybackSettingsKeys.SubtitleBackgroundOpacity, + PlaybackSettingsKeys.SubtitleTextOutline, + PlaybackSettingsKeys.SubtitleTextOutlineColor, + PlaybackSettingsKeys.SubtitlePosition, + ) + + /** + * Merges the granular [fields] over [base]. Values are the store's plain + * strings; a field that is absent, blank, or not a member of its enum is + * skipped rather than reset to a default, because the granular slots are a + * sparse overlay and a bad value must not erase a good one. + */ + fun project( + fields: Map, + base: SubtitleAppearance = SubtitleAppearance.DEFAULT, + ): SubtitleAppearance { + var out = base + fields[PlaybackSettingsKeys.SubtitleFontSize]?.let { raw -> + fontSize(raw)?.let { out = out.copy(fontSize = it) } + } + fields[PlaybackSettingsKeys.SubtitleFontFamily]?.let { raw -> + raw.trim().takeIf { it.isNotEmpty() }?.let { out = out.copy(fontFamily = it) } + } + fields[PlaybackSettingsKeys.SubtitleTextColor]?.let { raw -> + hexColor(raw)?.let { out = out.copy(fontColor = it) } + } + fields[PlaybackSettingsKeys.SubtitleBackgroundColor]?.let { raw -> + hexColor(raw)?.let { out = out.copy(backgroundColor = it) } + } + fields[PlaybackSettingsKeys.SubtitleBackgroundStyle]?.let { raw -> + backgroundStyle(raw)?.let { out = out.copy(backgroundStyle = it) } + } + fields[PlaybackSettingsKeys.SubtitleBackgroundOpacity]?.let { raw -> + raw.trim().toIntOrNull()?.let { out = out.copy(backgroundOpacity = it.coerceIn(0, 100)) } + } + fields[PlaybackSettingsKeys.SubtitleTextOutline]?.let { raw -> + raw.trim().lowercase().toBooleanStrictOrNull()?.let { out = out.copy(textOutline = it) } + } + fields[PlaybackSettingsKeys.SubtitleTextOutlineColor]?.let { raw -> + hexColor(raw)?.let { out = out.copy(textOutlineColor = it) } + } + fields[PlaybackSettingsKeys.SubtitlePosition]?.let { raw -> + position(raw)?.let { out = out.copy(position = it) } + } + return out.sanitized() + } + + /** + * The granular spelling of [appearance] — every key present, so writing + * this back over the local slots leaves nothing stale behind. + */ + fun flatten(appearance: SubtitleAppearance): Map { + val safe = appearance.sanitized() + return mapOf( + PlaybackSettingsKeys.SubtitleFontSize to safe.fontSize.wire, + PlaybackSettingsKeys.SubtitleFontFamily to safe.fontFamily, + PlaybackSettingsKeys.SubtitleTextColor to safe.fontColor, + PlaybackSettingsKeys.SubtitleBackgroundColor to safe.backgroundColor, + PlaybackSettingsKeys.SubtitleBackgroundStyle to safe.backgroundStyle.wire, + PlaybackSettingsKeys.SubtitleBackgroundOpacity to safe.backgroundOpacity.toString(), + PlaybackSettingsKeys.SubtitleTextOutline to safe.textOutline.toString(), + PlaybackSettingsKeys.SubtitleTextOutlineColor to safe.textOutlineColor, + PlaybackSettingsKeys.SubtitlePosition to safe.position.wire, + ) + } + + private fun fontSize(raw: String): SubtitleFontSizePreset? { + val v = raw.trim().lowercase() + return SubtitleFontSizePreset.entries.firstOrNull { it.wire == v } + } + + private fun backgroundStyle(raw: String): SubtitleBackgroundStylePreset? { + val v = raw.trim().lowercase() + return SubtitleBackgroundStylePreset.entries.firstOrNull { it.wire == v } + } + + private fun position(raw: String): SubtitlePositionPreset? { + val v = raw.trim().lowercase() + SubtitlePositionPreset.entries.firstOrNull { it.wire == v }?.let { return it } + // Older Android builds stored the numeric cue-line position. + return when (v.toIntOrNull()) { + null -> null + in Int.MIN_VALUE..34 -> SubtitlePositionPreset.Top + in 35..84 -> SubtitlePositionPreset.LowerThird + else -> SubtitlePositionPreset.Bottom + } + } + + private fun hexColor(raw: String): String? { + val trimmed = raw.trim() + val body = if (trimmed.startsWith("#")) trimmed.drop(1) else trimmed + if (body.length != 6) return null + if (!body.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) return null + return "#" + body.lowercase() + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthInterceptor.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthInterceptor.kt deleted file mode 100644 index b8dde1189..000000000 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthInterceptor.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.prairieserver.prairie.network - -import io.ktor.client.plugins.api.* - -/** - * Ktor plugin that attaches auth headers to every request. - * Full implementation provided by Agent 2 in AuthInterceptorImpl.kt. - */ -val PrairieAuth = createClientPlugin("PrairieAuth") { - // Stub - Agent 2 provides the real implementation -} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/AuthApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/AuthApi.kt index 8d04833b4..08958b605 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/AuthApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/AuthApi.kt @@ -64,6 +64,37 @@ class AuthApi(private val client: HttpClient) { } } + /** + * Resolves an emailed-invitation claim token against the given server. + * Unauthenticated: this runs before any account exists. + */ + suspend fun lookupInvitation( + serverUrl: String, + token: String, + ): ApiResult = safeApiCall { + // The token arrives from an emailed link and is not ours to trust as + // path-safe: a '/' or '?' in it would otherwise re-shape the request. + client.get("${serverUrl.trimEnd('/')}/api/v1/invitations/${token.encodeURLPathPart()}") { + skipPrairieAuth() + } + } + + /** + * Accepts an invitation: creates the account (username = the invitation's + * email) and returns a normal login response. + */ + suspend fun acceptInvitation( + serverUrl: String, + token: String, + password: String, + ): ApiResult = safeApiCall { + client.post("${serverUrl.trimEnd('/')}/api/v1/invitations/${token.encodeURLPathPart()}/accept") { + skipPrairieAuth() + contentType(ContentType.Application.Json) + setBody(AcceptInvitationRequest(password = password)) + } + } + suspend fun getMe(): ApiResult = safeApiCall { client.get("/api/v1/auth/me") } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/OnboardingApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/OnboardingApi.kt new file mode 100644 index 000000000..1ba0f6576 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/OnboardingApi.kt @@ -0,0 +1,39 @@ +package org.prairieserver.prairie.network.api + +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType +import org.prairieserver.prairie.model.onboarding.OnboardingFlow +import org.prairieserver.prairie.model.onboarding.OnboardingProgressRequest +import org.prairieserver.prairie.model.onboarding.OnboardingState +import org.prairieserver.prairie.network.ApiResult + +/** + * Server-driven onboarding tour. All endpoints are profile-scoped — the auth + * interceptor attaches X-Profile-Id, so these are only callable once a + * profile is active. + */ +class OnboardingApi(private val client: HttpClient) { + + /** surface is "phone" or "tv"; the server filters unsuitable steps. */ + suspend fun getFlow(surface: String): ApiResult = safeApiCall { + client.get("/api/v1/onboarding/flow") { + parameter("surface", surface) + } + } + + suspend fun getState(): ApiResult = safeApiCall { + client.get("/api/v1/onboarding/state") + } + + suspend fun postProgress(request: OnboardingProgressRequest): ApiResult = safeApiCall { + client.post("/api/v1/onboarding/progress") { + contentType(ContentType.Application.Json) + setBody(request) + } + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/SettingsApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/SettingsApi.kt index d16d0f0db..b70d3b47a 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/SettingsApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/SettingsApi.kt @@ -1,22 +1,33 @@ package org.prairieserver.prairie.network.api +import org.prairieserver.prairie.model.settings.EffectiveSettingValuesResponse import org.prairieserver.prairie.model.settings.EffectiveSettingsResponse import org.prairieserver.prairie.model.settings.EffectiveSubtitleAppearance import org.prairieserver.prairie.model.settings.PlaybackSettingsKeys import org.prairieserver.prairie.model.settings.SettingEntry +import org.prairieserver.prairie.model.settings.SettingScope +import org.prairieserver.prairie.model.settings.SettingScopeIdentity +import org.prairieserver.prairie.model.settings.SettingValueWriteRequest +import org.prairieserver.prairie.model.settings.SettingsContractCapabilities import org.prairieserver.prairie.model.settings.SettingsListResponse +import org.prairieserver.prairie.model.settings.StoredSettingValue import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.settings.UpdateSettingRequest import org.prairieserver.prairie.network.ApiResult import io.ktor.client.HttpClient +import io.ktor.client.request.HttpRequestBuilder import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement import io.ktor.client.request.delete import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.put import io.ktor.client.request.setBody import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode import io.ktor.http.contentType +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid /** * Admin-configured card-overlay baseline. `enabled` is the global @@ -31,6 +42,49 @@ data class OverlayConfigResponse( val defaults: String? = null, ) +/** + * Result of probing the canonical settings contract. + * + * A server that predates the canonical settings API has no + * `/api/v1/settings/contract` routes at all, so the probe 404s. That is a + * distinct, actionable state — the UI must say "this server needs an + * upgrade" rather than render an empty settings screen — so it is a typed + * case here instead of dissolving into the generic error path. + */ +sealed class SettingsCapabilitiesResult { + /** The server speaks the canonical settings API. */ + data class Available( + val capabilities: SettingsContractCapabilities, + ) : SettingsCapabilitiesResult() + + /** + * The connected server does not serve `/api/v1/settings/contract` + * (HTTP 404): it is too old for the canonical settings API. + */ + data object ServerUpgradeRequired : SettingsCapabilitiesResult() + + /** Any other HTTP failure, with the server's error body when present. */ + data class Error( + val code: Int, + val error: String, + val message: String, + ) : SettingsCapabilitiesResult() + + /** The request never reached the server. */ + data class NetworkError(val exception: Throwable) : SettingsCapabilitiesResult() +} + +/** + * A fresh idempotency key for one settings write. + * + * Generate one per logical write and hold it across retries: the server + * replays the recorded receipt for a repeated id with identical content, and + * rejects the id with 409 `mutation_id_conflict` when it was used for + * different content. Generating a new id per retry would defeat both. + */ +@OptIn(ExperimentalUuidApi::class) +fun newSettingMutationId(): String = Uuid.random().toString() + open class SettingsApi(private val client: HttpClient) { open suspend fun getSettings(): ApiResult = safeApiCall { @@ -101,4 +155,139 @@ open class SettingsApi(private val client: HttpClient) { open suspend fun deleteDeviceSubtitleAppearanceOverride(): ApiResult = deleteDeviceSetting(PlaybackSettingsKeys.SubtitleAppearance) + + // ------------------------------------------------------------------ + // Canonical settings API (/settings/contract, /settings/values/*). + // Typed JSON values with explicit scopes; the endpoints above speak the + // legacy string-only registry and remain for not-yet-migrated call sites. + // ------------------------------------------------------------------ + + /** + * What the connected server's settings contract supports, or + * [SettingsCapabilitiesResult.ServerUpgradeRequired] when the server + * predates the canonical settings API entirely. + * + * Not every 404 on this path means an old server. The route sits behind + * the viewer-access middleware, which answers a JSON + * `{"error":"not_found"}` when the `X-Profile-Id` this client sends names + * a profile the household deleted elsewhere. Telling the user their + * server is too old — and to go ask its admin — when the real fix is + * re-selecting a profile is worse than saying nothing, so the two are + * separated on the wire: a server with no `/settings/contract` routes + * falls through to the router's plain-text `404 page not found`, which + * leaves the parsed error code empty. + */ + open suspend fun getContractCapabilities(): SettingsCapabilitiesResult = + when (val result = safeApiCall { + client.get("/api/v1/settings/contract/capabilities") + }) { + is ApiResult.Success -> SettingsCapabilitiesResult.Available(result.data) + is ApiResult.Error -> + if (result.code == HttpStatusCode.NotFound.value && result.error.isEmpty()) { + SettingsCapabilitiesResult.ServerUpgradeRequired + } else { + SettingsCapabilitiesResult.Error(result.code, result.error, result.message) + } + is ApiResult.NetworkError -> SettingsCapabilitiesResult.NetworkError(result.exception) + } + + /** + * Resolve settings the way the server does, including the scope each + * answer came from. + * + * Batched on purpose: one request serves a whole settings screen or a + * season view spanning several series. Passing no [keys] resolves every + * remote definition in the server's contract. [libraryIds] and + * [seriesIds] widen the resolution to those content scopes; the profile + * and device parts of the context come from the session headers the auth + * interceptor already attaches. + */ + open suspend fun getEffectiveValues( + keys: List = emptyList(), + libraryIds: List = emptyList(), + seriesIds: List = emptyList(), + ): ApiResult = safeApiCall { + client.get("/api/v1/settings/values/effective") { + url { + if (keys.isNotEmpty()) parameters.append("keys", keys.joinToString(",")) + if (libraryIds.isNotEmpty()) { + parameters.append("library_ids", libraryIds.joinToString(",")) + } + if (seriesIds.isNotEmpty()) { + parameters.append("series_ids", seriesIds.joinToString(",")) + } + } + } + } + + /** + * Write one typed value at one scope. + * + * [mutationId] (sent as `X-Prairie-Mutation-Id`) makes retries safe: create + * it once per logical write with [newSettingMutationId] and reuse it for + * every retry of that write. A retry the server already applied replays + * the recorded receipt instead of re-applying; reusing an id for + * *different* content fails with 409 `mutation_id_conflict`. + * + * A value that exceeds a policy restriction is stored, not rejected — the + * restriction caps it at resolution time — so a 200 receipt does not mean + * the value is what playback will use. Resolve via [getEffectiveValues] + * for that. + */ + open suspend fun putValue( + key: String, + scope: SettingScopeIdentity, + value: JsonElement, + mutationId: String, + profileId: String? = null, + ): ApiResult = safeApiCall { + client.put("/api/v1/settings/values/$key") { + applyScopeIdentity(scope, profileId) + if (mutationId.isNotBlank()) { + header("X-Prairie-Mutation-Id", mutationId) + } + contentType(ContentType.Application.Json) + setBody(SettingValueWriteRequest(value)) + } + } + + /** + * Clear the explicit value at one scope, so the setting inherits again. + * + * 204 on success; 404 `not_found` when nothing was set there, which a + * retrying caller should treat as already done. + */ + open suspend fun deleteValue( + key: String, + scope: SettingScopeIdentity, + profileId: String? = null, + ): ApiResult = safeApiCall { + client.delete("/api/v1/settings/values/$key") { + applyScopeIdentity(scope, profileId) + } + } + + /** + * Applies the parts of a scope identity that travel with the request. + * + * Scope and the content ids go in the query. The profile normally rides + * the session's `X-Profile-Id` header; an explicit [profileId] overrides + * it (the interceptor only fills the header when absent), matching how + * [setDeviceSetting] lets a parent act for a child profile. The device id + * is never set here — the interceptor always attaches + * `X-Prairie-Device-Id`, and appending it again would send two values. + */ + private fun HttpRequestBuilder.applyScopeIdentity( + scope: SettingScopeIdentity, + profileId: String?, + ) { + url { + parameters.append("scope", scope.scope.wire) + scope.libraryId?.let { parameters.append("library_id", it.toString()) } + scope.seriesId?.let { parameters.append("series_id", it) } + } + if (!profileId.isNullOrBlank() && scope.scope != SettingScope.ACCOUNT) { + header("X-Profile-Id", profileId) + } + } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlaySchema.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlaySchema.kt index 065d4a64d..cee4b8422 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlaySchema.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlaySchema.kt @@ -15,8 +15,8 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive /** - * Encodes/decodes [CardOverlayPrefs] to/from the JSON string the server - * stores under the `card_overlays` user setting. The shape MUST stay + * Encodes/decodes [CardOverlayPrefs] to/from the JSON object stored in the + * canonical `ui.card_overlays` profile setting. The shape MUST stay * compatible with web's `parseOverlayPrefs`, iOS `OverlaySchema`, and * tvOS, since clients share the setting. * @@ -55,7 +55,7 @@ object OverlaySchema { } /** - * Parse a JSON string into a fully populated [CardOverlayPrefs]. + * Parse a JSON object string into a fully populated [CardOverlayPrefs]. * Unknown overlay IDs and malformed entries are dropped — the * remaining fields fall back to registry defaults so the user's real * overrides survive a schema upgrade. V1 docs (flat diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlayTypes.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlayTypes.kt index cd1c76b48..53b65f63e 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlayTypes.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlayTypes.kt @@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable /** * Stable identifiers for every overlay the system knows about. The wire - * format (server `defaults.card_overlays`, user `card_overlays`) uses + * format (server `defaults.card_overlays`, profile `ui.card_overlays`) uses * these as keys, so they MUST stay in sync with web's `OverlayId`, * iOS `OverlayId`, and tvOS. Adding a new overlay requires updating the * registry; renaming an existing one is a breaking change for stored @@ -153,9 +153,9 @@ data class OverlayItemConfig( /** * Versioned root document stored under the user setting key - * `card_overlays`. Serialized as a JSON string and PUT to - * `/api/v1/settings/card_overlays`. Shared across web, iOS, tvOS, and - * Android. + * `ui.card_overlays`. Serialized as a typed JSON object and PUT to + * `/api/v1/settings/values/ui.card_overlays?scope=profile`. Shared across + * web, iOS, tvOS, and Android. */ data class CardOverlayPrefs( val version: Int, diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleLanguage.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleLanguage.kt index 9b8228dfc..11aa37a61 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleLanguage.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleLanguage.kt @@ -8,6 +8,19 @@ package org.prairieserver.prairie.playback * ISO 639-1 tags. Region/script suffixes do not identify a different subtitle * artifact for the selection fallback, so matching uses the primary language. */ +/** + * A resolved subtitle preference, with "" collapsed back to null. + * + * The two representations mean the same thing in the settings store — the + * contract spells "no preference" as JSON null, the store spells it as the + * empty string — but they mean opposite things to subtitle auto-selection: a + * blank-but-present language is read as an explicit "off", while null means + * "nothing chosen, decide normally". Any preference crossing from settings + * into playback goes through here so the store's spelling cannot be mistaken + * for a user's choice. + */ +fun String?.orNullIfBlank(): String? = this?.takeIf { it.isNotBlank() } + fun canonicalSubtitleLanguage(language: String?): String? { val primary = language ?.trim() diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/AuthRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/AuthRepository.kt index 7de4e058f..b02026cdd 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/AuthRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/AuthRepository.kt @@ -11,6 +11,7 @@ import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.ServerRegistry import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.network.api.AuthApi +import org.prairieserver.prairie.model.auth.InvitationLookupResponse import org.prairieserver.prairie.network.api.HealthApi import org.prairieserver.prairie.network.map @@ -21,12 +22,12 @@ class AuthRepository( private val healthApi: HealthApi? = null, ) { /** - * Logs in with username and password. - * On success, persists tokens via [TokenManager] and returns the [User]. + * Persists a successful auth response's tokens into the active server's + * scope and unwraps the [User] — the shared tail of every path that ends + * a signed-out state (login, signup, setup, invitation claim). */ - suspend fun login(username: String, password: String): ApiResult { - val result = authApi.login(LoginRequest(username = username, password = password)) - return when (result) { + private suspend fun persistSession(result: ApiResult): ApiResult = + when (result) { is ApiResult.Success -> { val data = result.data tokenManager.saveTokens( @@ -39,7 +40,13 @@ class AuthRepository( is ApiResult.Error -> result is ApiResult.NetworkError -> result } - } + + /** + * Logs in with username and password. + * On success, persists tokens via [TokenManager] and returns the [User]. + */ + suspend fun login(username: String, password: String): ApiResult = + persistSession(authApi.login(LoginRequest(username = username, password = password))) /** * Credential login without persistence. TV keeps QR and password sign-in @@ -59,27 +66,16 @@ class AuthRepository( password: String, inviteCode: String, ): ApiResult { - val result = authApi.signup( - SignupRequest( - username = username, - email = email, - password = password, - inviteCode = inviteCode, + return persistSession( + authApi.signup( + SignupRequest( + username = username, + email = email, + password = password, + inviteCode = inviteCode, + ), ), ) - return when (result) { - is ApiResult.Success -> { - val data = result.data - tokenManager.saveTokens( - accessToken = data.accessToken, - refreshToken = data.refreshToken, - expiresIn = data.expiresIn, - ) - ApiResult.Success(data.user) - } - is ApiResult.Error -> result - is ApiResult.NetworkError -> result - } } /** @@ -90,22 +86,7 @@ class AuthRepository( username: String, email: String, password: String, - ): ApiResult { - val result = authApi.setup(username, email, password) - return when (result) { - is ApiResult.Success -> { - val data = result.data - tokenManager.saveTokens( - accessToken = data.accessToken, - refreshToken = data.refreshToken, - expiresIn = data.expiresIn, - ) - ApiResult.Success(data.user) - } - is ApiResult.Error -> result - is ApiResult.NetworkError -> result - } - } + ): ApiResult = persistSession(authApi.setup(username, email, password)) /** Checks whether the server requires initial setup. */ suspend fun getSetupStatus(): ApiResult = @@ -114,6 +95,46 @@ class AuthRepository( suspend fun getSetupStatus(serverUrl: String): ApiResult = authApi.getSetupStatus(serverUrl) + /** + * Resolves an emailed-invitation claim token against a server the app is + * not signed into yet. + */ + suspend fun lookupInvitation( + serverUrl: String, + token: String, + ): ApiResult = authApi.lookupInvitation(serverUrl, token) + + /** + * Accepts an emailed invitation: the account is created with the + * invitation's email as username, tokens are persisted, and the new + * [User] is returned — same post-conditions as [signup]. + * + * The claim request goes to [serverUrl] directly (it needs no auth), and + * the app only adopts that server as active once the claim has actually + * succeeded. Switching first would strand a user whose claim fails — + * expired token, already used, network error — on a server they have no + * account on, with their previous session no longer active. + */ + suspend fun acceptInvitation( + serverUrl: String, + token: String, + password: String, + ): ApiResult { + val result = authApi.acceptInvitation(serverUrl, token, password) + if (result is ApiResult.Success) { + setServerUrl(serverUrl) + // The server may already be registered with a previous account's + // profile scope, which setServerUrl just restored. The claimed + // account is a different identity — drop the stale profile id + + // token so its first requests don't carry another user's profile + // headers, and so the app lands on profile selection. + tokenManager.setProfileId(null) + tokenManager.setProfileToken(null) + tokenManager.getCurrentServerId()?.let { serverRegistry?.setProfileId(it, null) } + } + return persistSession(result) + } + /** Checks whether public signups are enabled. */ suspend fun getSignupStatus(): ApiResult = authApi.getSignupStatus() diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/CatalogRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/CatalogRepository.kt index 1b697d11e..bdc166f9c 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/CatalogRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/CatalogRepository.kt @@ -11,8 +11,11 @@ import org.prairieserver.prairie.model.catalog.Person import org.prairieserver.prairie.model.catalog.SeasonsResponse import org.prairieserver.prairie.model.catalog.WatchDetail import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.network.api.CatalogApi import org.prairieserver.prairie.repository.port.CatalogCachePort +import org.prairieserver.prairie.repository.port.CatalogCacheWriteLease import org.prairieserver.prairie.repository.port.NoOpCatalogCachePort import org.prairieserver.prairie.repository.port.canServeCache @@ -20,6 +23,7 @@ class CatalogRepository( private val catalogApi: CatalogApi, /** Offline read cache for a library's default first page (Track B). No-op by default. */ private val catalogCache: CatalogCachePort = NoOpCatalogCachePort, + private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), ) { /** Browse the catalog with optional filters, sorting, and pagination. */ suspend fun browse( @@ -40,6 +44,7 @@ class CatalogRepository( queryGroups: List = emptyList(), match: String? = null, ): ApiResult { + val requestIdentityGeneration = identityTransitions.generation.value val result = catalogApi.getCatalog( source = source, query = query, @@ -71,7 +76,9 @@ class CatalogRepository( } ?: return result if (result is ApiResult.Success) { - catalogCache.cacheDefaultLibraryPage(cacheableLibraryId, result.data) + writeIfIdentityUnchanged(requestIdentityGeneration) { cacheWriteLease -> + catalogCache.cacheDefaultLibraryPage(cacheableLibraryId, result.data, cacheWriteLease) + } return result } if (result.canServeCache()) { @@ -109,9 +116,12 @@ class CatalogRepository( /** Fetches full metadata for a single catalog item (offline: last cached detail). */ suspend fun getItemDetail(contentId: String): ApiResult { + val requestIdentityGeneration = identityTransitions.generation.value val result = catalogApi.getItemDetail(contentId) if (result is ApiResult.Success) { - catalogCache.cacheItemDetail(contentId, result.data) + writeIfIdentityUnchanged(requestIdentityGeneration) { cacheWriteLease -> + catalogCache.cacheItemDetail(contentId, result.data, cacheWriteLease) + } return result } if (result.canServeCache()) { @@ -124,15 +134,27 @@ class CatalogRepository( suspend fun getCachedItemDetail(contentId: String): ItemDetail? = catalogCache.getCachedItemDetail(contentId) + /** + * Cache-first detail for speculative UI enrichment. Unlike a detail screen, + * prefetch must not re-download metadata that is already durable locally. + */ + suspend fun getItemDetailForPrefetch(contentId: String): ApiResult { + catalogCache.getCachedItemDetail(contentId)?.let { return ApiResult.Success(it) } + return getItemDetail(contentId) + } + /** Fetches playback-oriented detail (versions, user progress, intro/credits markers). */ suspend fun getWatchDetail(contentId: String): ApiResult = catalogApi.getWatchDetail(contentId) /** Lists seasons for a series (offline: last cached seasons). */ suspend fun getSeasons(seriesId: String): ApiResult { + val requestIdentityGeneration = identityTransitions.generation.value val result = catalogApi.getSeasons(seriesId) if (result is ApiResult.Success) { - catalogCache.cacheSeasons(seriesId, result.data) + writeIfIdentityUnchanged(requestIdentityGeneration) { cacheWriteLease -> + catalogCache.cacheSeasons(seriesId, result.data, cacheWriteLease) + } return result } if (result.canServeCache()) { @@ -143,9 +165,12 @@ class CatalogRepository( /** Lists episodes for a specific season of a series (offline: last cached episodes). */ suspend fun getEpisodes(seriesId: String, seasonNumber: Int): ApiResult { + val requestIdentityGeneration = identityTransitions.generation.value val result = catalogApi.getEpisodes(seriesId, seasonNumber) if (result is ApiResult.Success) { - catalogCache.cacheEpisodes(seriesId, seasonNumber, result.data) + writeIfIdentityUnchanged(requestIdentityGeneration) { cacheWriteLease -> + catalogCache.cacheEpisodes(seriesId, seasonNumber, result.data, cacheWriteLease) + } return result } if (result.canServeCache()) { @@ -189,4 +214,13 @@ class CatalogRepository( limit = limit, snapshotAt = snapshotAt, ) + + private suspend fun writeIfIdentityUnchanged( + requestGeneration: Long, + write: suspend (CatalogCacheWriteLease) -> Unit, + ) { + if (requestGeneration == identityTransitions.generation.value) { + write(CatalogCacheWriteLease(requestGeneration)) + } + } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/OnboardingRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/OnboardingRepository.kt new file mode 100644 index 000000000..746ae910f --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/OnboardingRepository.kt @@ -0,0 +1,31 @@ +package org.prairieserver.prairie.repository + +import org.prairieserver.prairie.model.onboarding.OnboardingFlow +import org.prairieserver.prairie.model.onboarding.OnboardingProgressRequest +import org.prairieserver.prairie.model.onboarding.OnboardingState +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.api.OnboardingApi + +/** + * First-run tour state and manifest. Completion is per profile and lives on + * the server, so finishing on any device silences every other one. + */ +class OnboardingRepository(private val api: OnboardingApi) { + + suspend fun getFlow(surface: String): ApiResult = api.getFlow(surface) + + suspend fun getState(): ApiResult = api.getState() + + suspend fun recordStep(tourId: String, stepId: String): ApiResult = + api.postProgress(OnboardingProgressRequest(tourId = tourId, lastStep = stepId)) + + suspend fun complete(tourId: String, lastStep: String?): ApiResult = + api.postProgress( + OnboardingProgressRequest(tourId = tourId, lastStep = lastStep, completed = true), + ) + + suspend fun skip(tourId: String, lastStep: String?): ApiResult = + api.postProgress( + OnboardingProgressRequest(tourId = tourId, lastStep = lastStep, skipped = true), + ) +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PersonalDataRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PersonalDataRepository.kt index 687fd1cf9..9e497492e 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PersonalDataRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PersonalDataRepository.kt @@ -7,9 +7,12 @@ import org.prairieserver.prairie.model.personal.SyncProgressItem import org.prairieserver.prairie.model.personal.SyncProgressRequest import org.prairieserver.prairie.model.personal.UserLibrary import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.network.api.PersonalDataApi import org.prairieserver.prairie.network.map import org.prairieserver.prairie.repository.port.CatalogCachePort +import org.prairieserver.prairie.repository.port.CatalogCacheWriteLease import org.prairieserver.prairie.repository.port.NoOpCatalogCachePort import org.prairieserver.prairie.repository.port.NoOpUserItemStatePort import org.prairieserver.prairie.repository.port.UserItemStatePort @@ -27,14 +30,18 @@ open class PersonalDataRepository( private val userItemStatePort: UserItemStatePort = NoOpUserItemStatePort, /** Offline read cache for the library list (Track B). No-op by default. */ private val catalogCache: CatalogCachePort = NoOpCatalogCachePort, + private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), ) { // -- Libraries -- /** Lists the libraries visible to the current user (offline: last cached list). */ suspend fun listUserLibraries(): ApiResult> { + val requestIdentityGeneration = identityTransitions.generation.value val result = personalDataApi.listUserLibraries() if (result is ApiResult.Success) { - catalogCache.cacheLibraries(result.data) + writeIfIdentityUnchanged(requestIdentityGeneration) { cacheWriteLease -> + catalogCache.cacheLibraries(result.data, cacheWriteLease) + } return result } if (result.canServeCache()) { @@ -164,4 +171,13 @@ open class PersonalDataRepository( open suspend fun dismissNextUp(itemId: String, seriesId: String): ApiResult = personalDataApi.dismissNextUp(itemId, seriesId) + + private suspend fun writeIfIdentityUnchanged( + requestGeneration: Long, + write: suspend (CatalogCacheWriteLease) -> Unit, + ) { + if (requestGeneration == identityTransitions.generation.value) { + write(CatalogCacheWriteLease(requestGeneration)) + } + } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SectionRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SectionRepository.kt index e76bf1458..0bb8b17ba 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SectionRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SectionRepository.kt @@ -7,34 +7,101 @@ import org.prairieserver.prairie.model.section.LibraryCollection import org.prairieserver.prairie.model.section.LibraryCollectionsResponse import org.prairieserver.prairie.model.section.SectionsResponse import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.network.api.SectionApi import org.prairieserver.prairie.network.map import org.prairieserver.prairie.repository.port.CatalogCachePort +import org.prairieserver.prairie.repository.port.CatalogCacheWriteLease import org.prairieserver.prairie.repository.port.NoOpCatalogCachePort import org.prairieserver.prairie.repository.port.canServeCache +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock class SectionRepository( private val sectionApi: SectionApi, /** Offline read cache for a library's Recommended sections (Track B). No-op by default. */ private val catalogCache: CatalogCachePort = NoOpCatalogCachePort, + private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), + private val homeRequestDispatcher: CoroutineDispatcher = Dispatchers.Default, ) { + private val homeRequestScope = CoroutineScope(SupervisorJob() + homeRequestDispatcher) + private val homeRequestMutex = Mutex() + private val homeSectionsInFlight = + mutableMapOf>>() + private val homeSectionItemsInFlight = + mutableMapOf, Deferred>>() + /** Fetches the home screen layout configuration. */ suspend fun getHomeLayout(): ApiResult = sectionApi.getHomeLayout() /** Fetches all home screen sections (with items pre-resolved). */ - suspend fun getHomeSections(): ApiResult = - sectionApi.getHomeSections() + suspend fun getHomeSections(): ApiResult { + val identityGeneration = identityTransitions.generation.value + val request = homeRequestMutex.withLock { + homeSectionsInFlight[identityGeneration] ?: run { + lateinit var created: Deferred> + created = homeRequestScope.async(start = CoroutineStart.LAZY) { + try { + sectionApi.getHomeSections() + } finally { + homeRequestMutex.withLock { + if (homeSectionsInFlight[identityGeneration] === created) { + homeSectionsInFlight.remove(identityGeneration) + } + } + } + } + homeSectionsInFlight[identityGeneration] = created + created.start() + created + } + } + return request.await() + } /** Fetches the items within a specific home section. */ - suspend fun getHomeSectionItems(sectionId: String): ApiResult = - sectionApi.getHomeSectionItems(sectionId) + suspend fun getHomeSectionItems(sectionId: String): ApiResult { + val requestKey = identityTransitions.generation.value to sectionId + val request = homeRequestMutex.withLock { + homeSectionItemsInFlight[requestKey] ?: run { + lateinit var created: Deferred> + created = homeRequestScope.async(start = CoroutineStart.LAZY) { + try { + sectionApi.getHomeSectionItems(sectionId) + } finally { + homeRequestMutex.withLock { + if (homeSectionItemsInFlight[requestKey] === created) { + homeSectionItemsInFlight.remove(requestKey) + } + } + } + } + homeSectionItemsInFlight[requestKey] = created + created.start() + created + } + } + return request.await() + } /** Fetches a library's resolved sections (offline: last cached sections). */ suspend fun getLibrarySections(libraryId: Int): ApiResult { + val requestIdentityGeneration = identityTransitions.generation.value + val cacheWriteLease = CatalogCacheWriteLease(requestIdentityGeneration) val result = sectionApi.getLibrarySections(libraryId) if (result is ApiResult.Success) { - catalogCache.cacheLibrarySections(libraryId, result.data.sections) + if (requestIdentityGeneration == identityTransitions.generation.value) { + catalogCache.cacheLibrarySections(libraryId, result.data.sections, cacheWriteLease) + } return result } if (result.canServeCache()) { diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SettingsRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SettingsRepository.kt index be218bec7..6a29bf654 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SettingsRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SettingsRepository.kt @@ -1,12 +1,18 @@ package org.prairieserver.prairie.repository import org.prairieserver.prairie.model.settings.EffectiveSetting +import org.prairieserver.prairie.model.settings.EffectiveSettingValue import org.prairieserver.prairie.model.settings.EffectiveSubtitleAppearance +import org.prairieserver.prairie.model.settings.SettingScopeIdentity +import org.prairieserver.prairie.model.settings.StoredSettingValue import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.api.OverlayConfigResponse import org.prairieserver.prairie.network.api.SettingsApi +import org.prairieserver.prairie.network.api.SettingsCapabilitiesResult +import org.prairieserver.prairie.network.api.newSettingMutationId import org.prairieserver.prairie.network.map +import kotlinx.serialization.json.JsonElement class SettingsRepository( private val settingsApi: SettingsApi, @@ -42,6 +48,63 @@ class SettingsRepository( response.settings.associateBy { it.key } } + /** + * Batched canonical resolution (`GET /api/v1/settings/values/effective`): + * typed JSON values, each with the scope it resolved from. A key the + * server's contract does not know is simply absent from the map. + */ + suspend fun getEffectiveValues( + keys: List = emptyList(), + libraryIds: List = emptyList(), + seriesIds: List = emptyList(), + ): ApiResult> = + settingsApi.getEffectiveValues(keys, libraryIds, seriesIds).map { response -> + response.settings.associateBy { it.key } + } + + /** + * What the connected server's settings contract supports, or + * [SettingsCapabilitiesResult.ServerUpgradeRequired] when it predates the + * canonical settings API. Screens surface that case as an explanation + * rather than as an empty list of settings. + */ + suspend fun contractCapabilities(): SettingsCapabilitiesResult = + settingsApi.getContractCapabilities() + + /** + * Write one profile-scoped value (`scope=profile`) — the household + * preference that applies on every device until a device overrides it. + * + * A fresh mutation id per call is correct here because one call is one + * logical write: these callers are settings pickers that roll their UI + * back on failure, so a user re-picking is genuinely new content and must + * not replay an id (that is exactly the 409 `mutation_id_conflict` case). + * A caller that retries the *same* write must pass the id it already used. + */ + suspend fun setProfileValue( + key: String, + value: JsonElement, + mutationId: String = newSettingMutationId(), + ): ApiResult = + settingsApi.putValue( + key = key, + scope = SettingScopeIdentity.profile(), + value = value, + mutationId = mutationId, + ) + + /** + * Clear the profile-scoped value so the setting inherits again. 404 means + * nothing was stored there, which is the state the caller asked for, so it + * reports success rather than an error the UI would have to special-case. + */ + suspend fun clearProfileValue(key: String): ApiResult = + when (val result = settingsApi.deleteValue(key, SettingScopeIdentity.profile())) { + is ApiResult.Error -> + if (result.code == 404) ApiResult.Success(Unit) else result + else -> result + } + suspend fun getEffectiveSubtitleAppearance(): ApiResult = settingsApi.getEffectiveSubtitleAppearance() diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepository.kt index eeb3d49c2..55ba3488b 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepository.kt @@ -18,7 +18,9 @@ import org.prairieserver.prairie.network.RoomRealtimeEvent import org.prairieserver.prairie.network.WatchTogetherRealtimeClient import org.prairieserver.prairie.network.api.WatchTogetherApi import org.prairieserver.prairie.util.parseRfc3339ToEpochMillis +import org.prairieserver.prairie.watchtogether.RoomDeliveryEcho import org.prairieserver.prairie.watchtogether.RoomDeliveryLatch +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway import org.prairieserver.prairie.watchtogether.RoomSessionRepository import org.prairieserver.prairie.watchtogether.RoomTransportIntent import org.prairieserver.prairie.watchtogether.roomTransportAuthorized @@ -97,7 +99,7 @@ class WatchTogetherRepository( private val realtimeFactory: () -> WatchTogetherRealtimeClient? = { null }, private val monotonicNowMs: () -> Long = { MONOTONIC_ORIGIN.elapsedNow().inWholeMilliseconds }, private val authScopeProvider: suspend () -> AuthScopeSnapshot? = { null }, -) : RoomSessionRepository { +) : RoomSessionRepository, WatchTogetherEntryGateway { /** Successful delivery state follows the process connection, not a UI controller. */ val roomDeliveryLatch = RoomDeliveryLatch() @@ -119,9 +121,11 @@ class WatchTogetherRepository( private var activeConnectionOwner: Long? = null private val _roomSnapshot = MutableStateFlow(null) private val _suggestions = MutableStateFlow>(emptyList()) + private val _roomDeliveryEcho = MutableStateFlow(null) override val roomSnapshot: StateFlow = _roomSnapshot.asStateFlow() val suggestions: StateFlow> = _suggestions.asStateFlow() + val roomDeliveryEcho: StateFlow = _roomDeliveryEcho.asStateFlow() private val _connectionState = MutableStateFlow(WatchTogetherConnectionState()) val connectionState: StateFlow = _connectionState.asStateFlow() @kotlin.concurrent.Volatile @@ -192,7 +196,7 @@ class WatchTogetherRepository( // ---- REST: create / join (store the room token) --------------------------- - suspend fun createRoom(request: CreateRoomRequest): ApiResult { + override suspend fun createRoom(request: CreateRoomRequest): ApiResult { val scope = authScopeProvider() ?: return missingAuthScope() val requestGeneration = beginRoomRequest() val r = api.createRoom(request, scope) @@ -200,7 +204,7 @@ class WatchTogetherRepository( return if (r is ApiResult.Success) installRoomResponse(r.data, scope, requestGeneration) else r } - suspend fun joinRoom(request: JoinRoomRequest): ApiResult { + override suspend fun joinRoom(request: JoinRoomRequest): ApiResult { val scope = authScopeProvider() ?: return missingAuthScope() val requestGeneration = beginRoomRequest() val r = api.joinRoom(request, scope) @@ -256,6 +260,7 @@ class WatchTogetherRepository( _suggestions.value = emptyList() _roomClosedReason.value = null _roomSnapshot.value = data.room + _roomDeliveryEcho.value = null _connectionState.value = WatchTogetherConnectionState(generation = installed.generation) realtimeConnectionId = null refreshTransportAuthorizationLocked() @@ -265,7 +270,7 @@ class WatchTogetherRepository( // ---- REST: host management ------------------------------------------------ - suspend fun setSelection(request: SetSelectionRequest): ApiResult { + override suspend fun setSelection(request: SetSelectionRequest): ApiResult { val lease = activeBinding() ?: return missingRoom() val r = api.setSelection(lease.roomId, lease.roomToken, request, lease.authScope) return publishRoomResponse(lease, r) @@ -310,7 +315,7 @@ class WatchTogetherRepository( val published = stateMutex.withLock { if (!isCurrentLocked(lease)) return@withLock false votedIds.add(suggestionId) - applySuggestions(r.data.suggestions, fromBroadcast = false) + applySuggestions(r.data.suggestions, fromBroadcast = true) true } return if (published) r else obsoleteRoomRequest() @@ -323,7 +328,7 @@ class WatchTogetherRepository( val published = stateMutex.withLock { if (!isCurrentLocked(lease)) return@withLock false votedIds.remove(suggestionId) - applySuggestions(r.data.suggestions, fromBroadcast = false) + applySuggestions(r.data.suggestions, fromBroadcast = true) true } return if (published) r else obsoleteRoomRequest() @@ -374,6 +379,7 @@ class WatchTogetherRepository( val published = stateMutex.withLock { if (!isCurrentLocked(lease)) return@withLock false _roomSnapshot.value = result.data.room + _roomDeliveryEcho.value = null refreshTransportAuthorizationLocked() true } @@ -384,12 +390,14 @@ class WatchTogetherRepository( lease: RoomBinding, result: ApiResult, expectedRoomRequest: Long? = null, + expectedConnectionOwner: Long? = null, ): ApiResult { if (result !is ApiResult.Success) return result val published = stateMutex.withLock { if ( !isCurrentLocked(lease) || - (expectedRoomRequest != null && latestRoomRequest != expectedRoomRequest) + (expectedRoomRequest != null && latestRoomRequest != expectedRoomRequest) || + (expectedConnectionOwner != null && activeConnectionOwner != expectedConnectionOwner) ) { return@withLock false } @@ -413,12 +421,14 @@ class WatchTogetherRepository( /** * Publish suggestions, re-merging `voted_by_me` from the local [votedIds] - * set. For REST results we also seed [votedIds] from authoritative - * voted_by_me; broadcasts force false, so we only OR the local set in. + * set. Authoritative REST lists replace [votedIds]; broadcasts and + * optimistic vote mutation responses preserve the local set because their + * per-recipient vote flags are not authoritative. */ private fun applySuggestions(list: List, fromBroadcast: Boolean) { if (!fromBroadcast) { - list.forEach { if (it.votedByMe) votedIds.add(it.id) } + votedIds.clear() + votedIds.addAll(list.filter { it.votedByMe }.map { it.id }) } _suggestions.value = list.map { s -> if (s.id in votedIds) s.copy(votedByMe = true) else s @@ -558,6 +568,7 @@ class WatchTogetherRepository( terminalGeneration = lease.generation _roomClosedReason.value = event.reason ?: "room_closed" _roomSnapshot.value = null + _roomDeliveryEcho.value = null refreshTransportAuthorizationLocked() } } @@ -568,6 +579,15 @@ class WatchTogetherRepository( } else if (event is RoomRealtimeEvent.Opened) { openedAtMs = monotonicNowMs() markOpened(lease, owner, client.currentConnectionId()) + publishSuggestionsResponse( + lease = lease, + result = api.listSuggestions( + lease.roomId, + lease.roomToken, + lease.authScope, + ), + expectedConnectionOwner = owner, + ) } else if ( event is RoomRealtimeEvent.SnapshotEvent && event.room.roomId == lease.roomId @@ -607,6 +627,7 @@ class WatchTogetherRepository( terminalGeneration = lease.generation _roomClosedReason.value = "connection_lost" _roomSnapshot.value = null + _roomDeliveryEcho.value = null refreshTransportAuthorizationLocked() } } @@ -694,6 +715,16 @@ class WatchTogetherRepository( is RoomRealtimeEvent.SnapshotEvent -> if (event.room.roomId == lease.roomId) { _roomSnapshot.value = event.room + _roomDeliveryEcho.value = event.room.attachedSessionId + ?.takeIf { it.isNotBlank() } + ?.let { sessionId -> + val connection = _connectionState.value + RoomDeliveryEcho( + connectionGeneration = connection.generation, + connectionEpoch = connection.epoch, + playbackSessionId = sessionId, + ) + } refreshTransportAuthorizationLocked() } is RoomRealtimeEvent.SuggestionsEvent -> applySuggestions(event.suggestions, fromBroadcast = true) @@ -729,6 +760,7 @@ class WatchTogetherRepository( binding = null terminalGeneration = null _roomSnapshot.value = null + _roomDeliveryEcho.value = null _suggestions.value = emptyList() _roomClosedReason.value = null votedIds.clear() diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/CatalogCachePort.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/CatalogCachePort.kt index 65687bb68..3f264660c 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/CatalogCachePort.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/CatalogCachePort.kt @@ -8,6 +8,12 @@ import org.prairieserver.prairie.model.personal.UserLibrary import org.prairieserver.prairie.model.section.ResolvedSection import org.prairieserver.prairie.network.ApiResult +/** + * Ownership captured before a cacheable network request starts. Identity-scoped + * cache implementations must reject the write when this generation is stale. + */ +data class CatalogCacheWriteLease(val identityGeneration: Long) + /** * Offline read cache for catalog browse (Track B). Backs repository-level * cache-with-fallback in [org.prairieserver.prairie.repository.PersonalDataRepository] @@ -22,24 +28,63 @@ import org.prairieserver.prairie.network.ApiResult */ interface CatalogCachePort { suspend fun cacheLibraries(libraries: List) {} + suspend fun cacheLibraries(libraries: List, lease: CatalogCacheWriteLease) { + cacheLibraries(libraries) + } suspend fun getCachedLibraries(): List? = null /** Cache the default (unfiltered, first-page) browse for a library. */ suspend fun cacheDefaultLibraryPage(libraryId: Int, response: CatalogResponse) {} + suspend fun cacheDefaultLibraryPage( + libraryId: Int, + response: CatalogResponse, + lease: CatalogCacheWriteLease, + ) { + cacheDefaultLibraryPage(libraryId, response) + } suspend fun getCachedDefaultLibraryPage(libraryId: Int): CatalogResponse? = null /** Cache a library's resolved "Recommended" sections (for the offline landing tab). */ suspend fun cacheLibrarySections(libraryId: Int, sections: List) {} + suspend fun cacheLibrarySections( + libraryId: Int, + sections: List, + lease: CatalogCacheWriteLease, + ) { + cacheLibrarySections(libraryId, sections) + } suspend fun getCachedLibrarySections(libraryId: Int): List? = null /** Cache an item's detail page (tap-a-title-offline). */ suspend fun cacheItemDetail(contentId: String, detail: ItemDetail) {} + suspend fun cacheItemDetail( + contentId: String, + detail: ItemDetail, + lease: CatalogCacheWriteLease, + ) { + cacheItemDetail(contentId, detail) + } suspend fun getCachedItemDetail(contentId: String): ItemDetail? = null /** Cache a series' season list + a season's episode list (offline series detail). */ suspend fun cacheSeasons(seriesId: String, response: SeasonsResponse) {} + suspend fun cacheSeasons( + seriesId: String, + response: SeasonsResponse, + lease: CatalogCacheWriteLease, + ) { + cacheSeasons(seriesId, response) + } suspend fun getCachedSeasons(seriesId: String): SeasonsResponse? = null suspend fun cacheEpisodes(seriesId: String, seasonNumber: Int, response: EpisodesResponse) {} + suspend fun cacheEpisodes( + seriesId: String, + seasonNumber: Int, + response: EpisodesResponse, + lease: CatalogCacheWriteLease, + ) { + cacheEpisodes(seriesId, seasonNumber, response) + } suspend fun getCachedEpisodes(seriesId: String, seasonNumber: Int): EpisodesResponse? = null } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/HomeCachePort.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/HomeCachePort.kt index 5dfd4e939..89f56444f 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/HomeCachePort.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/HomeCachePort.kt @@ -8,6 +8,8 @@ data class HomeCacheSnapshot( val cachedAtMs: Long, ) +data class HomeCacheWriteLease(val identityGeneration: Long) + /** * Offline read cache for the home screen (Track B). [HomeViewModel] serves the * cached layout instantly (stale-while-revalidate) so the app opens to content @@ -23,6 +25,9 @@ data class HomeCacheSnapshot( */ interface HomeCachePort { suspend fun cacheHome(sections: List) {} + suspend fun cacheHome(sections: List, lease: HomeCacheWriteLease) { + cacheHome(sections) + } suspend fun getCachedHome(): HomeCacheSnapshot? = null } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeSectionHydrator.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeSectionHydrator.kt new file mode 100644 index 000000000..4a4fc5418 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeSectionHydrator.kt @@ -0,0 +1,70 @@ +package org.prairieserver.prairie.viewmodel + +import org.prairieserver.prairie.model.section.HomeSectionItemsResponse +import org.prairieserver.prairie.model.section.ResolvedSection +import org.prairieserver.prairie.network.ApiResult +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit + +/** Complete-or-partial result shared by startup warmup and [HomeViewModel]. */ +data class HomeSectionHydration( + val sections: List, + val fullyResolved: Boolean, +) + +/** + * Keeps aggregate Home sections that already contain items and resolves only + * genuinely missing section payloads, with bounded fallback concurrency. + */ +suspend fun hydrateHomeSections( + sections: List, + maxConcurrency: Int = 4, + fetchItems: suspend (String) -> ApiResult, +): HomeSectionHydration { + require(maxConcurrency > 0) { "maxConcurrency must be positive" } + + val unresolved = sections.filter { it.items.isEmpty() && it.totalCount > 0 } + val semaphore = Semaphore(maxConcurrency) + val fallbackById = coroutineScope { + unresolved.map { section -> + async { + section.id to semaphore.withPermit { + when (val result = fetchItems(section.id)) { + is ApiResult.Success -> resolveHomeSectionItems(section, result.data) + is ApiResult.Error, + is ApiResult.NetworkError -> null + } + } + } + }.awaitAll().toMap() + } + + return HomeSectionHydration( + sections = sections.mapNotNull { section -> + when { + section.items.isNotEmpty() -> section + section.totalCount == 0 -> null + else -> fallbackById[section.id]?.takeIf { it.items.isNotEmpty() } + } + }, + fullyResolved = unresolved.all { fallbackById[it.id] != null }, + ) +} + +private fun resolveHomeSectionItems( + original: ResolvedSection, + response: HomeSectionItemsResponse, +): ResolvedSection? { + val responseSection = response.section + return when { + responseSection != null && responseSection.items.isNotEmpty() -> responseSection + responseSection != null && responseSection.totalCount == 0 -> responseSection + responseSection != null && response.items.isNotEmpty() -> + responseSection.copy(items = response.items) + response.items.isNotEmpty() -> original.copy(items = response.items) + else -> null + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModel.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModel.kt index 5059e8516..75c34d100 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModel.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModel.kt @@ -7,13 +7,14 @@ import org.prairieserver.prairie.model.catalog.MediaItemUserState import org.prairieserver.prairie.model.section.ResolvedSection import org.prairieserver.prairie.model.section.SectionItem import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.repository.SectionRepository import org.prairieserver.prairie.repository.port.HomeCachePort +import org.prairieserver.prairie.repository.port.HomeCacheWriteLease import org.prairieserver.prairie.repository.port.NoOpHomeCachePort import org.prairieserver.prairie.repository.port.NoOpUserItemStatePort import org.prairieserver.prairie.repository.port.UserItemStatePort -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -45,6 +46,7 @@ class HomeViewModel( // Live-home accelerator (Apple realtime-updates spec). Null keeps // commonMain/tests network-only; the apps inject the shared coordinator. private val homeRealtime: org.prairieserver.prairie.repository.HomeRealtimeCoordinator? = null, + private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), ) : ViewModel() { private val _uiState = MutableStateFlow(HomeUiState()) @@ -117,6 +119,8 @@ class HomeViewModel( } private suspend fun fetchSections() { + val requestIdentityGeneration = identityTransitions.generation.value + val cacheWriteLease = HomeCacheWriteLease(requestIdentityGeneration) // Whether we already have something to show (cached or prior fetch) — if a // refresh fails we keep it rather than replacing it with a blocking error. val hadSections = _uiState.value.sections.isNotEmpty() @@ -130,48 +134,20 @@ class HomeViewModel( // re-downloading data already in hand. Defensive fallback resolves // only sections the server left un-inlined (older deployments / a // section type that reports a non-zero total but ships no items). - val needsFetch = sections.filter { it.items.isEmpty() && it.totalCount > 0 } - val resolvedPairs: List> = if (needsFetch.isEmpty()) { - sections.map { it to true } - } else { - val byId = needsFetch.map { section -> - viewModelScope.async { - section.id to when (val itemsResult = sectionRepository.getHomeSectionItems(section.id)) { - is ApiResult.Success -> { - // The response carries items either nested under - // `section` or as a sibling top-level `items` list. - // Honor both — using only `.section` silently drops - // a successful refetch that returned items at the top - // level, leaving the section empty and filtered out. - val data = itemsResult.data - val responseSection = data.section - val hydrated = when { - responseSection != null && responseSection.items.isNotEmpty() -> - responseSection - responseSection != null && responseSection.totalCount == 0 -> - responseSection - responseSection != null && data.items.isNotEmpty() -> - responseSection.copy(items = data.items) - data.items.isNotEmpty() -> - section.copy(items = data.items) - else -> null - } - if (hydrated != null) hydrated to true else section to false - } - else -> section to false - } - } - }.awaitAll().toMap() - sections.map { section -> byId[section.id] ?: (section to true) } + val hydration = hydrateHomeSections(sections) { sectionId -> + sectionRepository.getHomeSectionItems(sectionId) } - val resolved = resolvedPairs.map { it.first }.filter { it.items.isNotEmpty() } + val resolved = hydration.sections // Don't persist a partially-resolved home over a good cached one. - val fullyResolved = resolvedPairs.all { it.second } + val fullyResolved = hydration.fullyResolved // Cache the RAW server sections (snapshot), but display with the // local optimistic overlay applied. - if (fullyResolved) { - homeCache.cacheHome(resolved) + if ( + fullyResolved && + requestIdentityGeneration == identityTransitions.generation.value + ) { + homeCache.cacheHome(resolved, cacheWriteLease) } val overlaid = overlayLocalState(resolved) _uiState.update { diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatch.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatch.kt index fa4c97b58..2bb4c122b 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatch.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatch.kt @@ -16,6 +16,12 @@ data class RoomDeliveryKey( val playbackSessionId: String, ) +data class RoomDeliveryEcho( + val connectionGeneration: Long, + val connectionEpoch: Long, + val playbackSessionId: String, +) + /** * Successful-delivery latches for one player controller. * @@ -45,6 +51,18 @@ class RoomDeliveryLatch { fun isAttached(key: RoomDeliveryKey?): Boolean = key != null && attached == key + /** + * Session-scoped traffic is safe only after the attach frame was delivered + * and the server echoed that exact session in a room snapshot. + */ + fun isServerAttached(key: RoomDeliveryKey?, echo: RoomDeliveryEcho?): Boolean = + key != null && + isAttached(key) && + echo != null && + echo.connectionGeneration == key.connectionGeneration && + echo.connectionEpoch == key.connectionEpoch && + echo.playbackSessionId == key.playbackSessionId + fun recordAttach(key: RoomDeliveryKey, delivered: Boolean) { if (delivered) attached = key } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryGateway.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryGateway.kt new file mode 100644 index 000000000..2968ea95a --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryGateway.kt @@ -0,0 +1,16 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest +import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.RoomResponse +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.network.ApiResult +import kotlinx.coroutines.flow.StateFlow + +interface WatchTogetherEntryGateway { + val roomSnapshot: StateFlow + suspend fun createRoom(request: CreateRoomRequest): ApiResult + suspend fun joinRoom(request: JoinRoomRequest): ApiResult + suspend fun setSelection(request: SetSelectionRequest): ApiResult +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicy.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicy.kt new file mode 100644 index 000000000..d1b3a791f --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicy.kt @@ -0,0 +1,23 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot + +enum class WatchTogetherEntryTarget { + Lobby, + Player, +} + +fun watchTogetherEntryTarget(room: RoomSnapshot): WatchTogetherEntryTarget = + if ( + !room.selectedContentId.isNullOrBlank() && + !(room.selfRole == MemberRole.Host && room.memberCount <= 1) + ) { + WatchTogetherEntryTarget.Player + } else { + WatchTogetherEntryTarget.Lobby + } + +fun resumableWatchTogetherRoom(room: RoomSnapshot?): RoomSnapshot? = + room?.takeIf { it.roomId.isNotBlank() && it.phase != RoomPhase.Ended } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/domain/settings/ProfileSettingsControllerTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/domain/settings/ProfileSettingsControllerTest.kt new file mode 100644 index 000000000..592b2ab85 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/domain/settings/ProfileSettingsControllerTest.kt @@ -0,0 +1,268 @@ +package org.prairieserver.prairie.domain.settings + +import org.prairieserver.prairie.model.settings.EffectiveSettingValue +import org.prairieserver.prairie.model.settings.EffectiveSettingValuesResponse +import org.prairieserver.prairie.model.settings.SettingScope +import org.prairieserver.prairie.model.settings.SettingScopeIdentity +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.model.settings.SettingsContractCapabilities +import org.prairieserver.prairie.model.settings.StoredSettingValue +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.api.SettingsApi +import org.prairieserver.prairie.network.api.SettingsCapabilitiesResult +import org.prairieserver.prairie.repository.SettingsRepository +import io.ktor.client.HttpClient +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ProfileSettingsControllerTest { + + private sealed class Call { + data class Put(val key: String, val scope: SettingScope, val value: JsonElement) : Call() + data class Delete(val key: String, val scope: SettingScope) : Call() + } + + private class FakeSettingsApi( + val capabilities: SettingsCapabilitiesResult = + SettingsCapabilitiesResult.Available(SettingsContractCapabilities(revision = 1)), + val effective: ApiResult = + ApiResult.Success(EffectiveSettingValuesResponse()), + val putResult: (String) -> ApiResult = { + ApiResult.Success(StoredSettingValue(key = it, scope = "profile")) + }, + val deleteResult: ApiResult = ApiResult.Success(Unit), + ) : SettingsApi(HttpClient()) { + + val calls = mutableListOf() + val mutationIds = mutableListOf() + + override suspend fun getContractCapabilities(): SettingsCapabilitiesResult = capabilities + + override suspend fun getEffectiveValues( + keys: List, + libraryIds: List, + seriesIds: List, + ): ApiResult = effective + + override suspend fun putValue( + key: String, + scope: SettingScopeIdentity, + value: JsonElement, + mutationId: String, + profileId: String?, + ): ApiResult { + calls += Call.Put(key, scope.scope, value) + mutationIds += mutationId + return putResult(key) + } + + override suspend fun deleteValue( + key: String, + scope: SettingScopeIdentity, + profileId: String?, + ): ApiResult { + calls += Call.Delete(key, scope.scope) + return deleteResult + } + } + + private fun controllerFor(api: SettingsApi) = + ProfileSettingsController(SettingsRepository(api)) + + @Test + fun `writes address scope profile with a mutation id`() = runTest { + val api = FakeSettingsApi() + controllerFor(api).setSubtitleMode("always") + + assertEquals>( + listOf( + Call.Put( + SettingKeys.PLAYBACK_SUBTITLE_MODE, + SettingScope.PROFILE, + JsonPrimitive("always"), + ), + ), + api.calls, + ) + assertTrue(api.mutationIds.single().isNotBlank(), "a write must carry an idempotency id") + } + + @Test + fun `an empty language clears the row rather than writing an empty string`() = runTest { + // The server's language_tag validator rejects "", and the contract + // spells "no preference" as the absence of a row. + val api = FakeSettingsApi() + val controller = controllerFor(api) + controller.setSubtitleLanguage("") + controller.setMetadataLanguage(" ") + + assertEquals>( + listOf( + Call.Delete(SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, SettingScope.PROFILE), + Call.Delete(SettingKeys.CATALOG_METADATA_LANGUAGE, SettingScope.PROFILE), + ), + api.calls, + ) + } + + @Test + fun `a non-empty language is written as the tag`() = runTest { + val api = FakeSettingsApi() + controllerFor(api).setSubtitleLanguage(" nl ") + + assertEquals>( + listOf( + Call.Put( + SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, + SettingScope.PROFILE, + JsonPrimitive("nl"), + ), + ), + api.calls, + ) + } + + @Test + fun `clearing an absent value succeeds`() = runTest { + // 404 means nothing was stored there, which is the state the caller + // asked for — reporting it as an error would roll the UI back from a + // change that did take effect. + val api = FakeSettingsApi( + deleteResult = ApiResult.Error(404, "not_found", "No value is set at this scope"), + ) + assertTrue(controllerFor(api).setSubtitleLanguage("").succeeded) + } + + @Test + fun `a failed write is reported so the caller can roll back`() = runTest { + val api = FakeSettingsApi( + putResult = { ApiResult.Error(400, "invalid_value", "nope") }, + ) + assertFalse(controllerFor(api).setShowForcedSubtitles(false).succeeded) + } + + @Test + fun `a successful write returns what the server actually resolves`() = runTest { + // A stored value is not necessarily the effective one: policy can + // narrow it, and a profile_device row outranks the profile row these + // setters write. Without the re-resolve the screen would keep showing + // the authored value while playback used the winning one. + val api = FakeSettingsApi( + effective = ApiResult.Success( + EffectiveSettingValuesResponse( + settings = listOf( + EffectiveSettingValue( + key = SettingKeys.PLAYBACK_SUBTITLE_MODE, + value = JsonPrimitive("always"), + source = "profile_device", + scope = "profile_device", + ), + ), + ), + ), + ) + + val result = controllerFor(api).setSubtitleMode("off") + + assertTrue(result.succeeded, "the write itself landed") + assertEquals( + "always", + result.snapshot?.subtitleMode, + "the caller must be handed the winning value, not the one it authored", + ) + } + + @Test + fun `a write whose re-resolve fails still reports success`() = runTest { + // The write landed; only the follow-up read did not. Reporting failure + // would roll the UI back from a change that did take effect. + val api = FakeSettingsApi( + effective = ApiResult.Error(500, "internal_error", "boom"), + ) + + val result = controllerFor(api).setSubtitleMode("off") + + assertTrue(result.succeeded) + assertEquals(null, result.snapshot, "no snapshot means: keep the optimistic value") + } + + @Test + fun `an old server reports upgrade required and no snapshot`() = runTest { + val api = FakeSettingsApi(capabilities = SettingsCapabilitiesResult.ServerUpgradeRequired) + val result = controllerFor(api).load() + + assertEquals(ProfileSettingsController.Availability.SERVER_UPGRADE_REQUIRED, result.availability) + assertEquals(null, result.snapshot, "values must not be invented for a server that has none") + } + + @Test + fun `load resolves the profile keys from the effective response`() = runTest { + val api = FakeSettingsApi( + effective = ApiResult.Success( + EffectiveSettingValuesResponse( + settings = listOf( + EffectiveSettingValue( + key = SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, + value = JsonPrimitive("nl"), + source = "explicit", + scope = "profile", + suggestedValues = listOf("en", "nl", "pt-BR"), + ), + EffectiveSettingValue( + key = SettingKeys.PLAYBACK_SUBTITLE_MODE, + value = JsonPrimitive("always"), + ), + EffectiveSettingValue( + key = SettingKeys.PLAYBACK_SHOW_FORCED_SUBTITLES, + value = JsonPrimitive(false), + ), + // A null language tag is "no preference", which the + // UI spells as the empty string. + EffectiveSettingValue( + key = SettingKeys.CATALOG_METADATA_LANGUAGE, + value = JsonNull, + ), + ), + ), + ), + ) + + val result = controllerFor(api).load() + + assertEquals(ProfileSettingsController.Availability.AVAILABLE, result.availability) + val snapshot = result.snapshot!! + assertEquals("nl", snapshot.subtitleLanguage) + assertEquals("always", snapshot.subtitleMode) + assertEquals(false, snapshot.showForcedSubtitles) + assertEquals("", snapshot.metadataLanguage) + assertEquals(listOf("en", "nl", "pt-BR"), snapshot.subtitleLanguageSuggestions) + } + + @Test + fun `absent keys fall back to the contract defaults`() = runTest { + val snapshot = controllerFor(FakeSettingsApi()).load().snapshot!! + + assertEquals("", snapshot.subtitleLanguage) + // The legacy empty string means unset, not a fourth mode. + assertEquals("auto", snapshot.subtitleMode) + // show_forced_subtitles defaults true server-side; defaulting false + // would silently turn forced subtitles off for untouched profiles. + assertEquals(true, snapshot.showForcedSubtitles) + } + + @Test + fun `a failed resolve reports unavailable rather than empty values`() = runTest { + val api = FakeSettingsApi(effective = ApiResult.Error(500, "internal_error", "boom")) + val result = controllerFor(api).load() + + assertEquals(ProfileSettingsController.Availability.UNAVAILABLE, result.availability) + assertEquals(null, result.snapshot) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/SharedModelsCoverageTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/SharedModelsCoverageTest.kt index 0cf2f7281..8924e8aad 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/SharedModelsCoverageTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/SharedModelsCoverageTest.kt @@ -153,7 +153,8 @@ class SharedModelsCoverageTest { Season(contentId = "s2", seasonNumber = 2, title = "Two", episodeCount = 8), Season(contentId = "s1", seasonNumber = 1, title = "One", episodeCount = 10, userData = SeasonUserData()), ) - assertEquals(listOf("s1", "s2", "s0"), seasons.sortedForDisplay().map { it.contentId }) + // Specials (season 0 / isSpecials) sort before regular seasons. + assertEquals(listOf("s0", "s1", "s2"), seasons.sortedForDisplay().map { it.contentId }) val browse = BrowseResponse(items = emptyList(), total = 0) assertEquals(0, json.decodeFromString(BrowseResponse.serializer(), json.encodeToString(BrowseResponse.serializer(), browse)).total) diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/SeasonDisplayOrderTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/SeasonDisplayOrderTest.kt new file mode 100644 index 000000000..fd3d1b5b3 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/SeasonDisplayOrderTest.kt @@ -0,0 +1,99 @@ +package org.prairieserver.prairie.model.catalog + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SeasonDisplayOrderTest { + private fun season( + number: Int, + specials: Boolean = false, + id: String = "season-$number-$specials", + title: String? = null, + ) = Season( + contentId = id, + seasonNumber = number, + isSpecials = specials, + title = title, + ) + + @Test + fun `specials sort before regular seasons`() { + val result = listOf(season(2), season(0), season(1)).sortedForDisplay() + assertEquals(listOf(0, 1, 2), result.map(Season::seasonNumber)) + } + + @Test + fun `specials flag is authoritative even for nonzero season number`() { + val result = listOf(season(1), season(99, specials = true), season(2)).sortedForDisplay() + assertEquals(listOf(99, 1, 2), result.map(Season::seasonNumber)) + } + + @Test + fun `ordinary opening selects first regular season`() { + val result = listOf(season(0), season(2), season(1)) + .initialSeasonForDisplay(preferredSeasonNumber = null) + assertEquals(1, result?.seasonNumber) + } + + @Test + fun `requested specials remains selected`() { + val result = listOf(season(2), season(0), season(1)) + .initialSeasonForDisplay(preferredSeasonNumber = 0) + assertEquals(0, result?.seasonNumber) + } + + @Test + fun `specials-only series selects specials`() { + val result = listOf(season(0)) + .initialSeasonForDisplay(preferredSeasonNumber = null) + assertEquals(0, result?.seasonNumber) + } + + @Test + fun `duplicate season numbers use title as deterministic tie breaker`() { + val result = listOf( + season(1, id = "z-id", title = "Zulu"), + season(1, id = "a-id", title = "Alpha"), + ).sortedForDisplay() + + assertEquals(listOf("a-id", "z-id"), result.map(Season::contentId)) + } + + @Test + fun `duplicate season numbers and titles use content id as deterministic tie breaker`() { + val result = listOf( + season(1, id = "z-id", title = "Same"), + season(1, id = "a-id", title = "Same"), + ).sortedForDisplay() + + assertEquals(listOf("a-id", "z-id"), result.map(Season::contentId)) + } + + @Test + fun `ordinary opening aligns selected state and episode request on first regular season`() { + val plan = listOf(season(2), season(0), season(1)) + .initialSeasonDisplayPlan(preferredSeasonNumber = null) + + assertEquals(listOf(0, 1, 2), plan.seasons.map(Season::seasonNumber)) + assertEquals(1, plan.selectedSeasonNumber) + assertEquals(1, plan.episodeRequestSeasonNumber) + } + + @Test + fun `explicit specials aligns selected state and episode request`() { + val plan = listOf(season(2), season(0), season(1)) + .initialSeasonDisplayPlan(preferredSeasonNumber = 0) + + assertEquals(0, plan.selectedSeasonNumber) + assertEquals(0, plan.episodeRequestSeasonNumber) + } + + @Test + fun `specials-only opening aligns selected state and episode request`() { + val plan = listOf(season(0)) + .initialSeasonDisplayPlan(preferredSeasonNumber = null) + + assertEquals(0, plan.selectedSeasonNumber) + assertEquals(0, plan.episodeRequestSeasonNumber) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicyTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicyTest.kt index b45225b19..766b69e02 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicyTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicyTest.kt @@ -1,11 +1,11 @@ package org.prairieserver.prairie.model.feature import kotlin.test.Test -import kotlin.test.assertTrue +import kotlin.test.assertFalse class ClientSurfacePolicyTest { @Test - fun watchTogetherIsExposedInTheDetailOverflows() { - assertTrue(CLIENT_WATCH_TOGETHER_SURFACE_ENABLED) + fun watchTogetherCodeStaysPresentButHiddenFromUserMenus() { + assertFalse(CLIENT_WATCH_TOGETHER_SURFACE_ENABLED) } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/navigation/MediaModeTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/navigation/MediaModeTest.kt index 163cdf70d..cb9e660bb 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/navigation/MediaModeTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/navigation/MediaModeTest.kt @@ -185,7 +185,7 @@ class MediaModeTest { * Leaving it out of the video types did not degrade it — it erased it: the type * mapped to no [MediaMode], so the library never reached navigation, search or * browse on either platform and nothing indicated anything was missing. - * silo-apple hit the same thing (#93). + * prairie-apple hit the same thing (#93). */ class MixedLibraryModeTest { diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/onboarding/OnboardingModelsTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/onboarding/OnboardingModelsTest.kt new file mode 100644 index 000000000..50ce20ad9 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/onboarding/OnboardingModelsTest.kt @@ -0,0 +1,96 @@ +package org.prairieserver.prairie.model.onboarding + +import org.prairieserver.prairie.network.PrairieJson +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OnboardingModelsTest { + + @Test + fun flowRoundTripsWithSettingStepAndUnknownKind() { + val flow = OnboardingFlow( + version = 2, + tourId = "phone-welcome", + steps = listOf( + OnboardingStep( + id = "intro", + kind = "copy", + title = "Welcome", + body = "A short tour.", + illustration = "welcome", + ), + OnboardingStep( + id = "audio", + kind = "setting", + setting = OnboardingSettingSpec( + target = "setting", + key = "playback.audio_language", + control = "picker", + options = listOf( + OnboardingSettingOption(value = "en", label = "English"), + OnboardingSettingOption(value = "ja", label = "Japanese"), + ), + default = "en", + label = "Audio language", + ), + ), + OnboardingStep( + id = "future", + kind = "unknown_future_kind", + ), + ), + ) + + val decoded = PrairieJson.decodeFromString(OnboardingFlow.serializer(), PrairieJson.encodeToString(flow)) + + assertEquals(2, decoded.version) + assertEquals("phone-welcome", decoded.tourId) + assertEquals(3, decoded.steps.size) + assertEquals("copy", decoded.steps[0].kind) + assertEquals("welcome", decoded.steps[0].illustration) + assertEquals("playback.audio_language", decoded.steps[1].setting?.key) + assertEquals(2, decoded.steps[1].setting?.options?.size) + assertEquals("en", decoded.steps[1].setting?.default) + assertEquals("unknown_future_kind", decoded.steps[2].kind) + assertNull(decoded.steps[2].setting) + } + + @Test + fun stateAndProgressRequestRoundTrip() { + val state = OnboardingState( + tourId = "tv-welcome", + lastStep = "audio", + completedAt = null, + skippedAt = "2026-07-01T00:00:00Z", + done = true, + ) + val progress = OnboardingProgressRequest( + tourId = "tv-welcome", + lastStep = "audio", + completed = false, + skipped = true, + ) + + val decodedState = + PrairieJson.decodeFromString(OnboardingState.serializer(), PrairieJson.encodeToString(state)) + val decodedProgress = + PrairieJson.decodeFromString( + OnboardingProgressRequest.serializer(), + PrairieJson.encodeToString(progress), + ) + + assertEquals("tv-welcome", decodedState.tourId) + assertEquals("audio", decodedState.lastStep) + assertNull(decodedState.completedAt) + assertEquals("2026-07-01T00:00:00Z", decodedState.skippedAt) + assertTrue(decodedState.done) + + assertEquals("tv-welcome", decodedProgress.tourId) + assertEquals("audio", decodedProgress.lastStep) + assertFalse(decodedProgress.completed) + assertTrue(decodedProgress.skipped) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackSubtitleChoicesTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackSubtitleChoicesTest.kt index ce657021e..54bc3f1cf 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackSubtitleChoicesTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackSubtitleChoicesTest.kt @@ -138,4 +138,35 @@ class PlaybackSubtitleChoicesTest { assertEquals(listOf(0), choices.map(PlayerSubtitleInfo::index)) assertEquals("/a.vtt", choices.single().url) } + + @Test + fun rebaseDownloadedSubtitleUrlRetargetsSessionPath() { + assertEquals( + "/stream/new-session/subtitles/3.vtt?token=a", + rebaseDownloadedSubtitleUrl( + "/stream/old-session/subtitles/3.vtt?token=a", + "new-session", + ), + ) + assertEquals( + "https://srv.example/stream/abc/subtitles/0.srt#frag", + rebaseDownloadedSubtitleUrl( + "https://srv.example/stream/xyz/subtitles/0.srt#frag", + "abc", + ), + ) + } + + @Test + fun rebaseDownloadedSubtitleUrlRejectsUnsafeTargetsAndNonMatches() { + val original = "/stream/old/subtitles/1.vtt" + assertEquals(original, rebaseDownloadedSubtitleUrl(original, "")) + assertEquals(original, rebaseDownloadedSubtitleUrl(original, "bad/id")) + assertEquals(original, rebaseDownloadedSubtitleUrl(original, "bad?id")) + assertEquals(original, rebaseDownloadedSubtitleUrl(original, "bad#id")) + assertEquals( + "/static/subtitles/1.vtt", + rebaseDownloadedSubtitleUrl("/static/subtitles/1.vtt", "new-session"), + ) + } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/SubtitleTrackMergeTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/SubtitleTrackMergeTest.kt index 1c92dedf7..99379402d 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/SubtitleTrackMergeTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/SubtitleTrackMergeTest.kt @@ -81,13 +81,13 @@ class SubtitleTrackMergeTest { existing = listOf(track(0, source = "embedded")), downloaded = listOf(downloaded(id = 312), downloaded(id = 313)), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) val reordered = mergeDownloadedSubtitles( existing = listOf(track(0, source = "embedded")), downloaded = listOf(downloaded(id = 313), downloaded(id = 312)), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) assertEquals(1, first.single { it.downloadId == 312 }.index) @@ -101,13 +101,13 @@ class SubtitleTrackMergeTest { existing = listOf(track(0, source = "embedded")), downloaded = listOf(downloaded(id = 312), downloaded(id = 313)), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) val afterDeletion = mergeDownloadedSubtitles( existing = listOf(track(0, source = "embedded")), downloaded = listOf(downloaded(id = 313)), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) assertEquals(2, beforeDeletion.single { it.downloadId == 313 }.index) @@ -121,13 +121,13 @@ class SubtitleTrackMergeTest { existing = listOf(track(0, source = "embedded")), downloaded = listOf(downloaded(id = 312)), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) val expandedCatalog = mergeDownloadedSubtitles( existing = listOf(track(0, source = "embedded"), track(7, source = "external")), downloaded = listOf(downloaded(id = 312)), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) assertEquals(1, shortCatalog.single { it.source == "downloaded" }.index) diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/profile/ProfileQualityPreferenceTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/profile/ProfileQualityPreferenceTest.kt index c61732c90..b800f852f 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/profile/ProfileQualityPreferenceTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/profile/ProfileQualityPreferenceTest.kt @@ -1,8 +1,12 @@ package org.prairieserver.prairie.model.profile +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class ProfileQualityPreferenceTest { @Test @@ -30,4 +34,13 @@ class ProfileQualityPreferenceTest { assertEquals("480p", displayProfileQualityPreference("480p")) assertEquals("Cinema", displayProfileQualityPreference(" Cinema ")) } + + @Test + fun createRequestCannotSendLegacyQualityButEditRequestStillCan() { + val create = Json.encodeToString(CreateProfileRequest(name = "New profile")) + val update = Json.encodeToString(UpdateProfileRequest(qualityPreference = "1080p")) + + assertFalse("quality_preference" in create) + assertTrue("\"quality_preference\":\"1080p\"" in update) + } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/LanguageOptionsTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/LanguageOptionsTest.kt new file mode 100644 index 000000000..a8b31e0eb --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/LanguageOptionsTest.kt @@ -0,0 +1,94 @@ +package org.prairieserver.prairie.model.settings + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class LanguageOptionsTest { + + @Test + fun `uses the definition-specific generated contract floor`() { + val options = LanguageOptions.namedOptions(SettingKeys.PLAYBACK_AUDIO_LANGUAGE) + val values = options.map { it.first } + + assertTrue("en" in values) + assertTrue("te" in values) + assertEquals(37, values.size) + assertTrue(options.all { it.second.isNotBlank() }) + } + + @Test + fun `unions runtime and exact current values without collapsing regions`() { + val values = LanguageOptions.namedOptions( + key = SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, + currentValue = "pt-BR", + runtimeValues = listOf("eng", "es-MX"), + ).map { it.first } + + assertTrue("en" in values) + assertFalse("eng" in values, "a true ISO alias must not create a duplicate row") + assertTrue("es-MX" in values) + assertTrue("pt" in values) + assertTrue("pt-BR" in values) + } + + @Test + fun `an exact current alias replaces the contract spelling`() { + val values = LanguageOptions.namedOptions( + key = SettingKeys.PLAYBACK_AUDIO_LANGUAGE, + currentValue = "eng", + ).map { it.first } + + assertTrue("eng" in values) + assertFalse("en" in values) + } + + @Test + fun `each definition supplies its own unset label`() { + assertEquals( + LanguageOptions.UNSET to "No preference", + LanguageOptions.options(SettingKeys.PLAYBACK_AUDIO_LANGUAGE).first(), + ) + assertEquals( + LanguageOptions.UNSET to "None", + LanguageOptions.options(SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE).first(), + ) + assertEquals( + LanguageOptions.UNSET to "Library default", + LanguageOptions.options(SettingKeys.CATALOG_METADATA_LANGUAGE).first(), + ) + } + + @Test + fun `labels and wire values round trip through the rendered options`() { + val options = LanguageOptions.options( + SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, + currentValue = "pt-BR", + ) + for ((wire, label) in options) { + assertEquals(wire, LanguageOptions.wireValue(label, options)) + } + assertNotEquals( + "None", + LanguageOptions.label("pt-BR", SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE), + ) + assertEquals( + "None", + LanguageOptions.label("English", SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE), + ) + } + + @Test + fun `legacy label values migrate while valid tags survive`() { + assertEquals("en", LanguageOptions.migrateLegacyValue("English")) + assertEquals("ja", LanguageOptions.migrateLegacyValue("Japanese")) + assertEquals("pt-BR", LanguageOptions.migrateLegacyValue("pt-BR")) + assertEquals("zh-Hant", LanguageOptions.migrateLegacyValue("zh-Hant")) + assertEquals("eng", LanguageOptions.migrateLegacyValue("eng")) + assertEquals(LanguageOptions.UNSET, LanguageOptions.migrateLegacyValue("Klingon")) + assertEquals(LanguageOptions.UNSET, LanguageOptions.migrateLegacyValue("Off")) + assertEquals(LanguageOptions.UNSET, LanguageOptions.migrateLegacyValue("Default")) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/QualityPresetsTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/QualityPresetsTest.kt new file mode 100644 index 000000000..2aced684d --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/QualityPresetsTest.kt @@ -0,0 +1,137 @@ +package org.prairieserver.prairie.model.settings + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The preset table is a client-side pairing of two contract settings, so the + * invariants worth pinning are the ones a silent edit could break: every + * resolution must be a member the server's enum accepts, every bitrate must + * sit inside the contract's range, and the table must round-trip — a preset + * written and read back has to select itself, or the picker shows the wrong + * entry for a value the user just chose. + * + * Mirrors web/src/lib/qualityPresets.test.ts. + */ +class QualityPresetsTest { + + // playback.preferred_quality's enum, and playback.max_bitrate_kbps's bounds. + private val contractResolutions = setOf("auto", "480p", "720p", "1080p", "2160p", "original") + private val minBitrate = 100 + private val maxBitrate = 200_000 + + @Test + fun `every preset uses contract-legal values`() { + for (preset in QualityPresets.ALL) { + assertTrue( + preset.resolution in contractResolutions, + "${preset.id}: ${preset.resolution} is not a playback.preferred_quality member", + ) + preset.bitrateKbps?.let { + assertTrue( + it in minBitrate..maxBitrate, + "${preset.id}: $it is outside playback.max_bitrate_kbps bounds", + ) + } + } + } + + @Test + fun `preset ids and axis pairs are unique`() { + assertEquals( + QualityPresets.ALL.size, + QualityPresets.ALL.map { it.id }.toSet().size, + "duplicate preset id", + ) + assertEquals( + QualityPresets.ALL.size, + QualityPresets.ALL.map { it.resolution to it.bitrateKbps }.toSet().size, + "two presets store the same pair, so one can never be selected", + ) + } + + @Test + fun `every preset round-trips through its stored pair`() { + for (preset in QualityPresets.ALL) { + assertEquals( + preset.id, + QualityPresets.presetFor(preset.resolution, preset.bitrateKbps)?.id, + "${preset.id} does not select itself", + ) + } + } + + @Test + fun `the table matches the web client's semantics`() { + // Named explicitly rather than derived, so a retune on one platform + // that is not mirrored on the other shows up here. + assertEquals( + listOf( + "auto" to null, + "original" to null, + "2160p" to null, + "1080p" to 10000, + "1080p" to 6000, + "1080p" to 3000, + "720p" to 4000, + "720p" to 2000, + "480p" to 1500, + ), + QualityPresets.ALL.map { it.resolution to it.bitrateKbps }, + ) + assertEquals( + listOf( + "Auto", "Original", "4K", "1080p High", "1080p", "1080p Low", + "720p High", "720p", "480p", + ), + QualityPresets.ALL.map { it.label }, + ) + } + + @Test + fun `a combination no preset covers selects nothing but still describes`() { + assertNull(QualityPresets.presetFor("1080p", 4500)) + assertEquals("1080p at 4.5 Mbps", QualityPresets.describe("1080p", 4500)) + assertEquals("4K at 25 Mbps", QualityPresets.describe("2160p", 25000)) + } + + @Test + fun `uncapped is the absence of a bitrate`() { + assertEquals("auto", QualityPresets.presetFor("auto", null)?.id) + // 0 is the local store's spelling of uncapped; it must not read as a cap. + assertEquals("auto", QualityPresets.presetFor("auto", 0)?.id) + assertEquals("Auto", QualityPresets.describe("auto", 0)) + } + + @Test + fun `legacy compound resolutions decompose to the enum member`() { + // These are the transcode-ladder spellings older builds stored. The + // bitrate they encoded is deliberately dropped: it lives on its own + // axis now, and inventing a cap would throttle playback silently. + assertEquals("1080p", QualityPresets.normalizeResolution("1080p-high")) + assertEquals("1080p", QualityPresets.normalizeResolution("1080p-8")) + assertEquals("720p", QualityPresets.normalizeResolution("720p-high")) + assertEquals("2160p", QualityPresets.normalizeResolution("4K")) + assertEquals("auto", QualityPresets.normalizeResolution("328p")) + assertEquals("auto", QualityPresets.normalizeResolution("")) + assertEquals("auto", QualityPresets.normalizeResolution(null)) + assertEquals("original", QualityPresets.normalizeResolution("Original")) + } + + @Test + fun `presetFor normalizes before matching so a legacy value still selects`() { + assertEquals("2160p", QualityPresets.presetFor("4k", null)?.id) + assertEquals("1080p", QualityPresets.presetFor("1080p-high", 6000)?.id) + } + + @Test + fun `byId and describe cover uncapped original and compound 4k`() { + assertEquals("original", QualityPresets.byId("original")?.id) + assertNull(QualityPresets.byId("missing")) + assertEquals("Original", QualityPresets.describe("original", null)) + assertEquals("Original at 12 Mbps", QualityPresets.describe("original", 12000)) + assertEquals("2160p", QualityPresets.normalizeResolution("4k-high")) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SettingKeysContractTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SettingKeysContractTest.kt new file mode 100644 index 000000000..9416e3e02 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SettingKeysContractTest.kt @@ -0,0 +1,138 @@ +package org.prairieserver.prairie.model.settings + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Pins this client's hand-written key list against the generated contract. + * + * PlaybackSettingsKeys predates the contract and still exists because Android + * carries local-only keys the server has no opinion about. What it must not do + * is disagree with the contract about the keys they share — a name that drifts + * is a setting the server rejects, and the drift is invisible until a user's + * write silently stops persisting. + */ +class SettingKeysContractTest { + + @Test + fun everyKeyAndroidFlushesIsOneTheServerStores() { + val remote = SettingKeys.REMOTE.toSet() + + // Android flattens subtitle appearance into granular fields locally; the + // contract carries the composite object instead, so those are expected + // to be absent from REMOTE and are projected before flushing. + val locallyFlattened = PlaybackSettingsKeys.DeviceSettings.filter { + it.startsWith("subtitle.") + }.toSet() + + val unknown = PlaybackSettingsKeys.DeviceSettings.toSet() - remote - locallyFlattened + assertTrue( + unknown.isEmpty(), + "these keys are flushed to the server but have no contract definition, " + + "so the server will reject them: $unknown", + ) + } + + @Test + fun localOnlyKeysAreNeverFlushed() { + // The contract's client_local persistence and Android's exclusion from + // DeviceSettings are the same statement. A key the contract calls local + // that Android flushes anyway would poison a whole settings batch. + val flushed = PlaybackSettingsKeys.DeviceSettings.toSet() + for (key in SettingKeys.CLIENT_LOCAL) { + // Android suffixes two of these with ".local"; compare on the base. + assertTrue( + key !in flushed, + "$key is client_local in the contract but Android flushes it", + ) + } + } + + @Test + fun theSharedKeysAgreeOnSpelling() { + // The specific pairs that drifted before the contract existed. Android + // shipped player.next_up_prompt_seconds while Apple and the server used + // playback.next_up_prompt_seconds, so the same preference was two + // settings and neither client could read the other's. + assertEquals(SettingKeys.PLAYBACK_AUDIO_LANGUAGE, PlaybackSettingsKeys.AudioLanguage) + assertEquals(SettingKeys.PLAYBACK_PREFERRED_QUALITY, PlaybackSettingsKeys.PreferredQuality) + assertEquals(SettingKeys.PLAYBACK_AUTO_SKIP_INTRO, PlaybackSettingsKeys.AutoSkipIntro) + assertEquals(SettingKeys.PLAYBACK_AUTO_SKIP_CREDITS, PlaybackSettingsKeys.AutoSkipCredits) + assertEquals(SettingKeys.PLAYBACK_AUTO_PLAY_NEXT, PlaybackSettingsKeys.AutoPlayNext) + // The two the cutover actually renamed. The membership checks above + // still pass if either constant points at some *other* contract key, + // so name both pairs explicitly. + assertEquals( + SettingKeys.PLAYBACK_SUBTITLE_APPEARANCE, + PlaybackSettingsKeys.SubtitleAppearance, + ) + assertEquals( + SettingKeys.PLAYBACK_NEXT_UP_PROMPT_SECONDS, + PlaybackSettingsKeys.NextUpPromptSeconds, + ) + assertEquals(SettingKeys.PLAYER_PLAYBACK_SPEED, PlaybackSettingsKeys.PlaybackSpeed) + assertEquals(SettingKeys.PLAYER_AUDIO_SYNC_MS, PlaybackSettingsKeys.AudioSyncMs) + assertEquals(SettingKeys.PLAYER_SUBTITLE_SYNC_MS, PlaybackSettingsKeys.SubtitleSyncMs) + assertEquals(SettingKeys.PLAYER_HDR_ENABLED, PlaybackSettingsKeys.HdrEnabled) + assertEquals(SettingKeys.PLAYER_VIDEO_GRAVITY, PlaybackSettingsKeys.VideoGravity) + assertEquals(SettingKeys.PLAYER_ORIENTATION_MODE, PlaybackSettingsKeys.OrientationMode) + assertEquals( + SettingKeys.PLAYER_SLEEP_TIMER_DEFAULT_MINUTES, + PlaybackSettingsKeys.SleepTimerDefaultMinutes, + ) + assertEquals( + SettingKeys.PLAYER_MATCH_FRAME_RATE, + PlaybackSettingsKeys.MatchContentFrameRate, + ) + } + + @Test + fun theTypeTablesComeFromTheContract() { + // Every generated classification must be a key the contract also lists + // as remote, or the tables describe settings that cannot be written. + val remote = SettingKeys.REMOTE.toSet() + for (key in SettingKeys.BOOLEAN_KEYS + SettingKeys.INT_KEYS + SettingKeys.DOUBLE_KEYS) { + assertTrue(key in remote, "$key is classified but not remote") + } + + // And the three must not overlap: a key in two tables would parse + // differently depending on which check ran first. + assertTrue((SettingKeys.BOOLEAN_KEYS intersect SettingKeys.INT_KEYS).isEmpty()) + assertTrue((SettingKeys.BOOLEAN_KEYS intersect SettingKeys.DOUBLE_KEYS).isEmpty()) + assertTrue((SettingKeys.INT_KEYS intersect SettingKeys.DOUBLE_KEYS).isEmpty()) + } + + @Test + fun theRenameTableTargetsTheKeysThatWereRenamed() { + // The local migration copies each old slot into the value on the right, + // so a target that drifts off the contract would move the user's value + // into a slot nothing reads — the same silent revert the table exists + // to prevent, just one rename later. + assertEquals( + mapOf( + "subtitle_appearance" to SettingKeys.PLAYBACK_SUBTITLE_APPEARANCE, + "player.next_up_prompt_seconds" to SettingKeys.PLAYBACK_NEXT_UP_PROMPT_SECONDS, + ), + PlaybackSettingsKeys.RenamedLocalKeys, + ) + // And every old spelling must be genuinely retired: a key that is still + // live would have its value copied out from under it. + for (oldKey in PlaybackSettingsKeys.RenamedLocalKeys.keys) { + assertTrue( + oldKey !in SettingKeys.REMOTE, + "$oldKey is still a contract key; renaming it locally would strand it", + ) + } + } + + @Test + fun qualityIsTwoAxesHere() { + // The compound ladder values are gone; a client composes a resolution + // and a bitrate. Both keys have to exist for the picker to offer the + // presets the phone and TV UIs show. + assertTrue(SettingKeys.PLAYBACK_PREFERRED_QUALITY in SettingKeys.REMOTE) + assertTrue(SettingKeys.PLAYBACK_MAX_BITRATE_KBPS in SettingKeys.REMOTE) + assertTrue(SettingKeys.PLAYBACK_MAX_BITRATE_KBPS in SettingKeys.INT_KEYS) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SettingsManifest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SettingsManifest.kt new file mode 100644 index 000000000..b4691f53c --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SettingsManifest.kt @@ -0,0 +1,122 @@ +package org.prairieserver.prairie.model.settings + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** + * The parts of `contracts/settings/v1/manifest.json` that resolution depends on. + * + * The generated bindings in [SettingKeys] carry key names and a coarse type + * table, which is all production code needs — Android writes through + * `/settings/values` and reads effective values back from the server. They do + * not carry the facts a *resolver* needs: each definition's resolution order, + * its default value, whether its enum members form a ranked progression, and + * what policy input constrains it. Rather than hand-copy those into Kotlin + * (which is exactly the drift the contract exists to remove), the conformance + * runner parses the vendored manifest and is driven by it. + * + * Only the fields resolution reads are modelled, and parsing tolerates unknown + * ones. That asymmetry with the fixture — which is decoded strictly — is + * deliberate: the manifest is a vendored copy pinned by [revision], and the + * revision check is its drift gate. The fixture has no such pin, so an + * unrecognized field there is the only signal that its schema moved. + * + * Fields skipped on purpose, because nothing here ranks or validates values: + * `allowed_scopes` (a write-side concern, and its entries may be either a + * scope name or an object), the numeric `minimum`/`maximum` bounds (which may + * be either a bare number or a widening history array), and all of the UI + * metadata except the language option-set fields, which are retained to gate + * the generated picker presentation against the vendored manifest. + */ +@Serializable +data class SettingsManifest( + @SerialName("api_version") val apiVersion: Int, + val revision: Int, + @SerialName("option_sets") val optionSets: Map = emptyMap(), + val definitions: List, +) { + private val byKey: Map = definitions.associateBy { it.key } + + /** The definition for [key], or null when this manifest does not declare it. */ + fun lookup(key: String): SettingDefinition? = byKey[key] +} + +@Serializable +data class SettingDefinition( + val key: String, + val persistence: String, + @SerialName("resolution_order") val resolutionOrder: List, + @SerialName("value_schema") val valueSchema: SettingValueSchema, + // Required by the manifest schema and never absent, but always present as + // an explicit JSON value that may itself be null — so it is typed as a + // JsonElement and JsonNull is a real default, not a missing one. + @SerialName("default_value") val defaultValue: JsonElement, + @SerialName("constrained_by") val constrainedBy: SettingConstraintBinding? = null, + @SerialName("suggested_options") val suggestedOptions: String? = null, + @SerialName("unset_label") val unsetLabel: String? = null, +) { + /** True when the server stores this setting; client_local keys never resolve. */ + val isRemote: Boolean get() = persistence == PERSISTENCE_REMOTE + + /** True for the types that rank numerically rather than by enum position. */ + val isNumeric: Boolean + get() = valueSchema.type == TYPE_INTEGER || valueSchema.type == TYPE_NUMBER + + companion object { + const val PERSISTENCE_REMOTE = "remote" + const val TYPE_INTEGER = "integer" + const val TYPE_NUMBER = "number" + const val TYPE_ENUM = "enum" + } +} + +@Serializable +data class ContractOptionSet( + val type: String, + val options: List, +) + +@Serializable +data class ContractSuggestedOption( + val value: String, + @SerialName("introduced_in") val introducedIn: Int, +) + +@Serializable +data class SettingValueSchema( + val type: String, + /** Members in declared order; ranking uses the position, not the label. */ + val values: List = emptyList(), + /** Set when the members form a progression a ceiling or floor can cap along. */ + val ordered: Boolean = false, + val nullable: Boolean = false, +) + +@Serializable +data class SettingEnumMember( + val value: JsonElement, +) + +/** A definition's binding to the policy input that may narrow it. */ +@Serializable +data class SettingConstraintBinding( + @SerialName("policy_input") val policyInput: String, + val constraint: SettingConstraintKind, +) + +/** How a policy input narrows a resolved value. */ +@Serializable +enum class SettingConstraintKind { + @SerialName("ceiling") + CEILING, + + @SerialName("floor") + FLOOR, + + @SerialName("allowlist") + ALLOWLIST, + + @SerialName("locked") + LOCKED, +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SettingsResolve.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SettingsResolve.kt new file mode 100644 index 000000000..a3cce52ba --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SettingsResolve.kt @@ -0,0 +1,306 @@ +package org.prairieserver.prairie.model.settings + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Client-side settings resolution, mirroring the server's + * `internal/settingsresolve` semantics: the definition's declared resolution + * order decides which stored value wins, an identity absent from the context + * drops the scopes that need it, and policy constraints narrow the answer + * without destroying what the user authored. + * + * Android does not resolve settings in production — it writes through + * `/settings/values` and reads effective values back from + * `/settings/values/effective`, so the server stays the single authority. This + * resolver exists for one reason: the cross-platform conformance fixture in + * `contracts/settings/v1/conformance.json` names a Kotlin implementation as one + * of the four that must agree, and four independently written resolvers + * agreeing is what makes the fixture a drift gate rather than a tautology. It + * therefore lives in test sources, and every behavioral choice in it is pinned + * by [SettingsConformanceTest] — do not change one without the fixture + * agreeing. + * + * The Go implementation is `internal/settingsresolve/resolve.go` and the + * TypeScript one is `web/src/lib/settingsResolve.ts`; the three are meant to be + * readable side by side. + */ + +/** Where a resolved value came from, or "default" when nothing was stored. */ +object SettingSource { + const val DEFAULT = "default" + const val ACCOUNT = "account" + const val PROFILE = "profile" + const val PROFILE_DEVICE = "profile_device" + const val PROFILE_LIBRARY = "profile_library" + const val PROFILE_SERIES = "profile_series" +} + +/** One stored row, as the server's values API reports it. */ +data class StoredSettingRow( + val key: String, + val scope: String, + val profileId: String? = null, + val deviceId: String? = null, + val libraryId: Int? = null, + val seriesId: String? = null, + val value: JsonElement, +) + +/** The identity a resolution happens against. Absent fields drop their scopes. */ +data class SettingResolutionContext( + val profileId: String? = null, + val deviceId: String? = null, + val libraryIds: List = emptyList(), + val seriesIds: List = emptyList(), +) + +/** One resolved setting. */ +data class ResolvedSetting( + val key: String, + val value: JsonElement, + val source: String, + /** True when a policy constraint narrowed [value] away from what was stored. */ + val constrained: Boolean = false, + /** What the user authored (may be [JsonNull]); present only when constrained. */ + val storedValue: JsonElement? = null, + val constraintKind: SettingConstraintKind? = null, +) + +/** + * Resolves the effective value for each requested key against stored rows. + * + * Unknown and `client_local` keys are omitted rather than erroring, matching the + * server: they have no server-resolved answer, so a newer client asking for a + * setting this contract does not carry gets a short answer, not a failure. + * + * [constraintBindings] lets a caller — in practice the conformance runner — + * attach a constraint to a key the shipped manifest does not bind, so + * constraint kinds no definition currently carries stay testable. A key with no + * entry uses the manifest's own `constrained_by`. + */ +fun resolveSettingValues( + manifest: SettingsManifest, + keys: List, + stored: List, + context: SettingResolutionContext, + constraints: Map = emptyMap(), + constraintBindings: Map = emptyMap(), +): List { + val seen = mutableSetOf() + return keys.mapNotNull { key -> + if (!seen.add(key)) return@mapNotNull null + val definition = manifest.lookup(key) ?: return@mapNotNull null + if (!definition.isRemote) return@mapNotNull null + resolveOne(definition, stored, context, constraints, constraintBindings[key]) + } +} + +private fun resolveOne( + definition: SettingDefinition, + stored: List, + context: SettingResolutionContext, + constraints: Map, + bindingOverride: SettingConstraintBinding?, +): ResolvedSetting { + val candidates = stored.filter { it.key == definition.key } + + var value = definition.defaultValue + var source = SettingSource.DEFAULT + for (scope in definition.resolutionOrder) { + if (scope == SettingSource.DEFAULT) break + val row = pickForScope(scope, candidates, context) ?: continue + value = row.value + source = scope + break + } + + return applyConstraint( + definition, + ResolvedSetting(key = definition.key, value = value, source = source), + constraints, + bindingOverride, + ) +} + +/** + * Returns the candidate row for one scope, mirroring the server: an identity + * missing from the context matches nothing, and a tie between several content + * rows breaks deterministically by (libraryId, seriesId). + * + * The device case checks the context's device id is non-empty as well as equal. + * Without that, a caller with no device identity — the anonymous jellycompat + * seed — matches every row whose own device id is also empty, and one device's + * settings leak to every client. + * + * That non-empty guard is currently unpinned by the fixture, in every language: + * `missing_device_identity_drops_device_scope` supplies no device id in the + * context but every stored profile_device row it carries names "d1", so plain + * equality already excludes them and removing the guard still passes. It is + * kept because the Go and TypeScript resolvers both have it and a row with an + * empty device_id is reachable in production. Closing the gap means a case + * upstream in contracts/settings/v1/conformance.json — a stored profile_device + * row with an empty device_id against a context with none — so that all four + * runners gain it at once; fixing it only here would defeat the point. + */ +private fun pickForScope( + scope: String, + candidates: List, + context: SettingResolutionContext, +): StoredSettingRow? { + val profileId = context.profileId.orEmpty() + val deviceId = context.deviceId.orEmpty() + val matches = candidates.filter { row -> + if (row.scope != scope) return@filter false + when (scope) { + SettingSource.ACCOUNT -> true + SettingSource.PROFILE -> row.profileId.orEmpty() == profileId + SettingSource.PROFILE_DEVICE -> + row.profileId.orEmpty() == profileId && + row.deviceId.orEmpty() == deviceId && + deviceId.isNotEmpty() + SettingSource.PROFILE_LIBRARY -> + row.profileId.orEmpty() == profileId && + (row.libraryId ?: 0) in context.libraryIds + SettingSource.PROFILE_SERIES -> + row.profileId.orEmpty() == profileId && + row.seriesId.orEmpty() in context.seriesIds + else -> false + } + } + if (matches.size <= 1) return matches.firstOrNull() + // Deterministic rather than arbitrary: a batch spanning several libraries or + // series has no single right answer and the caller is expected to resolve + // per item, but two identical requests must not disagree. + return matches.sortedWith( + compareBy({ it.libraryId ?: 0 }, { it.seriesId.orEmpty() }), + ).first() +} + +/** + * Narrows an effective value to what policy permits without destroying the + * authored one: a preference capped today must take effect the day the cap + * lifts, so the stored value is reported alongside the cap rather than replaced. + */ +private fun applyConstraint( + definition: SettingDefinition, + resolved: ResolvedSetting, + constraints: Map, + bindingOverride: SettingConstraintBinding?, +): ResolvedSetting { + val binding = bindingOverride ?: definition.constrainedBy ?: return resolved + val limit = constraints[binding.policyInput] ?: return resolved + + val narrowed = narrowValue(definition, binding.constraint, resolved.value, limit) + ?: return resolved + return resolved.copy( + value = narrowed, + storedValue = resolved.value, + constrained = true, + constraintKind = binding.constraint, + ) +} + +/** Applies one constraint kind, returning the narrowed value or null when it stands. */ +private fun narrowValue( + definition: SettingDefinition, + kind: SettingConstraintKind, + value: JsonElement, + limit: JsonElement, +): JsonElement? = when (kind) { + // The policy value replaces the user's outright. An already-equal value is + // not a narrowing, so clients do not tell the user their own choice was + // overridden. + SettingConstraintKind.LOCKED -> + if (jsonEquivalent(value, limit)) null else limit + + SettingConstraintKind.CEILING -> when { + // null on a nullable numeric means "no cap of my own" — unbounded + // above, which is exactly what a ceiling exists to bring down. It has + // no numeric rank, so a plain comparison reports 0 and the one value + // that most needs capping would slip past. + value is JsonNull && definition.isNumeric -> limit + compareValues(definition, value, limit) <= 0 -> null + else -> limit + } + + // The mirror rule: unbounded above already satisfies any floor. + SettingConstraintKind.FLOOR -> when { + value is JsonNull && definition.isNumeric -> null + compareValues(definition, value, limit) >= 0 -> null + else -> limit + } + + SettingConstraintKind.ALLOWLIST -> { + val allowed = limit as? JsonArray + when { + allowed == null || allowed.isEmpty() -> null + allowed.any { jsonEquivalent(it, value) } -> null + // Falling back to the first allowed member rather than the + // definition default: the default may itself be outside the + // allowlist, and an effective value the policy forbids is the one + // thing this must never return. + else -> allowed.first() + } + } +} + +/** + * Ranks two values through the definition's own schema: numbers numerically, + * ordered enums by declared member position. Anything unrankable compares equal, + * so an unrecognized value is never silently narrowed — validation is a separate + * concern and has already rejected it by the time a constraint applies. + */ +private fun compareValues(definition: SettingDefinition, a: JsonElement, b: JsonElement): Int { + if (definition.isNumeric) { + val left = a.asDoubleOrNull() ?: return 0 + val right = b.asDoubleOrNull() ?: return 0 + return left.compareTo(right) + } + if (definition.valueSchema.type == SettingDefinition.TYPE_ENUM && + definition.valueSchema.ordered + ) { + val members = definition.valueSchema.values + val left = members.indexOfFirst { jsonEquivalent(it.value, a) } + val right = members.indexOfFirst { jsonEquivalent(it.value, b) } + if (left < 0 || right < 0) return 0 + return left.compareTo(right) + } + return 0 +} + +private fun JsonElement.asDoubleOrNull(): Double? { + val primitive = this as? JsonPrimitive ?: return null + if (primitive is JsonNull || primitive.isString) return null + return primitive.content.toDoubleOrNull() +} + +/** + * Structural equality over JSON values, ignoring object key order and numeric + * spelling. + * + * [JsonElement] already compares structurally, but it compares numbers by their + * source text: `8000` and `8000.0` are the same JSON number and must not be + * treated as different values. The Go runner decodes to `any` before comparing, + * which collapses both to a float64; this does the same by hand. + */ +fun jsonEquivalent(a: JsonElement, b: JsonElement): Boolean = when { + a is JsonNull || b is JsonNull -> a is JsonNull && b is JsonNull + a is JsonObject && b is JsonObject -> + a.keys == b.keys && a.all { (key, value) -> jsonEquivalent(value, b.getValue(key)) } + a is JsonArray && b is JsonArray -> + a.size == b.size && a.indices.all { jsonEquivalent(a[it], b[it]) } + a is JsonPrimitive && b is JsonPrimitive -> when { + a.isString != b.isString -> false + a.isString -> a.content == b.content + else -> { + val left = a.asDoubleOrNull() + val right = b.asDoubleOrNull() + if (left != null && right != null) left == right else a.content == b.content + } + } + else -> false +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceProjectionTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceProjectionTest.kt new file mode 100644 index 000000000..dc3825e1d --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceProjectionTest.kt @@ -0,0 +1,159 @@ +package org.prairieserver.prairie.model.settings + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The projection is what makes a per-field subtitle edit reach the server at + * all: the contract has one composite object and no definitions for the + * granular `subtitle.*` fields, so a field the projection drops is a + * preference that silently never syncs. + */ +class SubtitleAppearanceProjectionTest { + + @Test + fun `every field projects onto the composite`() { + val projected = SubtitleAppearanceProjection.project( + mapOf( + PlaybackSettingsKeys.SubtitleFontSize to "xxlarge", + PlaybackSettingsKeys.SubtitleFontFamily to "Avenir Next", + PlaybackSettingsKeys.SubtitleTextColor to "#ffee00", + PlaybackSettingsKeys.SubtitleBackgroundColor to "#101010", + PlaybackSettingsKeys.SubtitleBackgroundStyle to "box", + PlaybackSettingsKeys.SubtitleBackgroundOpacity to "40", + PlaybackSettingsKeys.SubtitleTextOutline to "true", + PlaybackSettingsKeys.SubtitleTextOutlineColor to "#001122", + PlaybackSettingsKeys.SubtitlePosition to "lower-third", + ), + ) + + assertEquals(SubtitleFontSizePreset.XXLarge, projected.fontSize) + assertEquals("Avenir Next", projected.fontFamily) + assertEquals("#ffee00", projected.fontColor) + assertEquals("#101010", projected.backgroundColor) + assertEquals(SubtitleBackgroundStylePreset.Box, projected.backgroundStyle) + assertEquals(40, projected.backgroundOpacity) + assertEquals(true, projected.textOutline) + assertEquals("#001122", projected.textOutlineColor) + assertEquals(SubtitlePositionPreset.LowerThird, projected.position) + } + + @Test + fun `projection is sparse over the base`() { + val base = SubtitleAppearance.DEFAULT.copy( + fontSize = SubtitleFontSizePreset.Small, + fontColor = "#123456", + backgroundOpacity = 20, + ) + // Only one field set; the rest of the base must survive untouched, + // matching the schema's "a stored value is a sparse override" rule. + val projected = SubtitleAppearanceProjection.project( + mapOf(PlaybackSettingsKeys.SubtitleTextOutline to "true"), + base, + ) + + assertEquals(SubtitleFontSizePreset.Small, projected.fontSize) + assertEquals("#123456", projected.fontColor) + assertEquals(20, projected.backgroundOpacity) + assertEquals(true, projected.textOutline) + } + + @Test + fun `a bad or absent field leaves the base value alone`() { + val base = SubtitleAppearance.DEFAULT.copy( + fontSize = SubtitleFontSizePreset.XLarge, + fontColor = "#abcdef", + position = SubtitlePositionPreset.Top, + ) + val projected = SubtitleAppearanceProjection.project( + mapOf( + PlaybackSettingsKeys.SubtitleFontSize to "enormous", + PlaybackSettingsKeys.SubtitleTextColor to "not-a-color", + PlaybackSettingsKeys.SubtitleFontFamily to " ", + PlaybackSettingsKeys.SubtitleTextOutline to "yes", + PlaybackSettingsKeys.SubtitlePosition to null, + ), + base, + ) + + assertEquals(SubtitleFontSizePreset.XLarge, projected.fontSize) + assertEquals("#abcdef", projected.fontColor) + assertEquals(base.fontFamily, projected.fontFamily) + assertEquals(base.textOutline, projected.textOutline) + assertEquals(SubtitlePositionPreset.Top, projected.position) + } + + @Test + fun `opacity is clamped rather than dropped`() { + assertEquals( + 100, + SubtitleAppearanceProjection.project( + mapOf(PlaybackSettingsKeys.SubtitleBackgroundOpacity to "180"), + ).backgroundOpacity, + ) + assertEquals( + 0, + SubtitleAppearanceProjection.project( + mapOf(PlaybackSettingsKeys.SubtitleBackgroundOpacity to "-5"), + ).backgroundOpacity, + ) + } + + @Test + fun `the legacy numeric position maps onto the enum`() { + fun positionFor(raw: String) = SubtitleAppearanceProjection.project( + mapOf(PlaybackSettingsKeys.SubtitlePosition to raw), + ).position + + assertEquals(SubtitlePositionPreset.Top, positionFor("0")) + assertEquals(SubtitlePositionPreset.LowerThird, positionFor("70")) + assertEquals(SubtitlePositionPreset.Bottom, positionFor("100")) + } + + @Test + fun `flatten then project is the identity`() { + val appearance = SubtitleAppearance( + fontSize = SubtitleFontSizePreset.XLarge, + fontFamily = "monospace", + fontColor = "#00ff00", + backgroundColor = "#220011", + backgroundStyle = SubtitleBackgroundStylePreset.Outline, + backgroundOpacity = 33, + textOutline = true, + textOutlineColor = "#334455", + position = SubtitlePositionPreset.Top, + ) + + // Projecting over the DEFAULT base, not over the appearance itself: + // a field flatten forgot would fall back to the default and be caught. + assertEquals( + appearance, + SubtitleAppearanceProjection.project( + SubtitleAppearanceProjection.flatten(appearance), + SubtitleAppearance.DEFAULT, + ), + ) + } + + @Test + fun `flatten covers every granular key`() { + assertEquals( + SubtitleAppearanceProjection.GRANULAR_KEYS.toSet(), + SubtitleAppearanceProjection.flatten(SubtitleAppearance.DEFAULT).keys, + ) + } + + @Test + fun `flattened enum values are the schema's spellings`() { + val flat = SubtitleAppearanceProjection.flatten( + SubtitleAppearance.DEFAULT.copy( + fontSize = SubtitleFontSizePreset.XXLarge, + backgroundStyle = SubtitleBackgroundStylePreset.None, + position = SubtitlePositionPreset.LowerThird, + ), + ) + assertEquals("xxlarge", flat[PlaybackSettingsKeys.SubtitleFontSize]) + assertEquals("none", flat[PlaybackSettingsKeys.SubtitleBackgroundStyle]) + assertEquals("lower-third", flat[PlaybackSettingsKeys.SubtitlePosition]) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/AuthApiTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/AuthApiTest.kt index 349ac6840..79bf69c6f 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/AuthApiTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/AuthApiTest.kt @@ -8,14 +8,18 @@ import org.prairieserver.prairie.network.PrairieJson import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.toByteArray import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.test.runTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertTrue class AuthApiTest { private val loginJson = """ @@ -23,9 +27,24 @@ class AuthApiTest { "user":{"id":1,"username":"u","email":"e","role":"user"}} """.trimIndent() - private fun api(body: String = loginJson, status: HttpStatusCode = HttpStatusCode.OK): AuthApi { + private class Captured { + var method: HttpMethod? = null + var url: String = "" + var body: String = "" + } + + private fun api( + body: String = loginJson, + status: HttpStatusCode = HttpStatusCode.OK, + captured: Captured? = null, + ): AuthApi { val client = HttpClient( - MockEngine { respond(body, status, headersOf(HttpHeaders.ContentType, "application/json")) }, + MockEngine { request -> + captured?.method = request.method + captured?.url = request.url.toString() + captured?.body = request.body.toByteArray().decodeToString() + respond(body, status, headersOf(HttpHeaders.ContentType, "application/json")) + }, ) { install(ContentNegotiation) { json(PrairieJson) } } return AuthApi(client) } @@ -53,4 +72,41 @@ class AuthApiTest { assertIs>(api(status = HttpStatusCode.NoContent, body = "").revokeSession("s1")) assertIs>(api(status = HttpStatusCode.NoContent, body = "").deleteSession("s1")) } + + @Test + fun lookupInvitationPathEncodesTokenAndParsesClaimPreview() = runTest { + val captured = Captured() + val result = api( + body = """ + {"email":"invitee@example.com","inviter_name":"Host", + "server_name":"Prairie","expires_at":"2026-08-01T00:00:00Z"} + """.trimIndent(), + captured = captured, + ).lookupInvitation("https://srv.example/", "tok/en?raw") + + assertEquals(HttpMethod.Get, captured.method) + assertTrue(captured.url.contains("/api/v1/invitations/")) + assertTrue(captured.url.contains("tok"), "token should remain in the path") + assertIs>(result) + val lookup = (result as ApiResult.Success).data + assertEquals("invitee@example.com", lookup.email) + assertEquals("Host", lookup.inviterName) + assertEquals("Prairie", lookup.serverName) + assertEquals("2026-08-01T00:00:00Z", lookup.expiresAt) + } + + @Test + fun acceptInvitationPostsPasswordAndReturnsLogin() = runTest { + val captured = Captured() + val result = api(captured = captured).acceptInvitation( + serverUrl = "https://srv.example", + token = "claim-token", + password = "secret-pass", + ) + + assertEquals(HttpMethod.Post, captured.method) + assertTrue(captured.url.endsWith("/api/v1/invitations/claim-token/accept")) + assertTrue(captured.body.contains("\"password\":\"secret-pass\"")) + assertIs>(result) + } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/OnboardingApiTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/OnboardingApiTest.kt new file mode 100644 index 000000000..7b793bab0 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/OnboardingApiTest.kt @@ -0,0 +1,117 @@ +package org.prairieserver.prairie.network.api + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.toByteArray +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.onboarding.OnboardingProgressRequest +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.PrairieJson +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class OnboardingApiTest { + + private class Captured { + var method: HttpMethod? = null + var path: String = "" + var query: Map = emptyMap() + var body: String = "" + } + + private fun api( + status: HttpStatusCode = HttpStatusCode.OK, + responseBody: String = "{}", + captured: Captured = Captured(), + ): Pair { + 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 = responseBody, + status = status, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + return OnboardingApi(client) to captured + } + + @Test + fun getFlowPassesSurfaceAndParsesSteps() = runTest { + val (api, captured) = api( + responseBody = """ + {"version":1,"tour_id":"phone-welcome","steps":[ + {"id":"intro","kind":"copy","title":"Hi","body":"Welcome"}, + {"id":"audio","kind":"setting", + "setting":{"target":"setting","key":"playback.audio_language", + "control":"picker","options":[{"value":"en","label":"English"}]}} + ]} + """.trimIndent(), + ) + + val result = api.getFlow("phone") + + assertEquals(HttpMethod.Get, captured.method) + assertEquals("/api/v1/onboarding/flow", captured.path) + assertEquals("phone", captured.query["surface"]) + assertIs>(result) + val flow = (result as ApiResult.Success).data + assertEquals("phone-welcome", flow.tourId) + assertEquals(2, flow.steps.size) + assertEquals("playback.audio_language", flow.steps[1].setting?.key) + } + + @Test + fun getStateParsesProgressFlags() = runTest { + val (api, captured) = api( + responseBody = """ + {"tour_id":"tv-welcome","last_step":"intro","done":false} + """.trimIndent(), + ) + + val result = api.getState() + + assertEquals("/api/v1/onboarding/state", captured.path) + assertIs>(result) + val state = (result as ApiResult.Success).data + assertEquals("tv-welcome", state.tourId) + assertEquals("intro", state.lastStep) + assertEquals(false, state.done) + } + + @Test + fun postProgressSendsJsonBody() = runTest { + val (api, captured) = api(status = HttpStatusCode.NoContent, responseBody = "") + + val result = api.postProgress( + OnboardingProgressRequest( + tourId = "phone-welcome", + lastStep = "audio", + completed = true, + ), + ) + + assertEquals(HttpMethod.Post, captured.method) + assertEquals("/api/v1/onboarding/progress", captured.path) + assertTrue(captured.body.contains("\"tour_id\":\"phone-welcome\"")) + assertTrue(captured.body.contains("\"last_step\":\"audio\"")) + assertTrue(captured.body.contains("\"completed\":true")) + assertIs>(result) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/SettingsApiValuesTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/SettingsApiValuesTest.kt new file mode 100644 index 000000000..a79594290 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/SettingsApiValuesTest.kt @@ -0,0 +1,337 @@ +package org.prairieserver.prairie.network.api + +import org.prairieserver.prairie.model.settings.EffectiveSettingValue +import org.prairieserver.prairie.model.settings.SettingScopeIdentity +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.PrairieJson +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.toByteArray +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Tests for the canonical settings API surface: `/settings/contract` and the `/settings/values` routes. */ +class SettingsApiValuesTest { + + private class Captured { + var method: HttpMethod? = null + var path: String = "" + var query: Map = emptyMap() + var headers: Headers = headersOf() + var body: String = "" + } + + private fun api( + status: HttpStatusCode = HttpStatusCode.OK, + responseBody: String = "{}", + responseContentType: String = "application/json", + captured: Captured = Captured(), + ): Pair { + 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.headers = request.headers + captured.body = request.body.toByteArray().decodeToString() + respond( + content = responseBody, + status = status, + headers = headersOf(HttpHeaders.ContentType, responseContentType), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + return SettingsApi(client) to captured + } + + // ---- capabilities / server-upgrade-required ---- + + @Test + fun `getContractCapabilities parses the server capabilities shape`() = runTest { + val (api, captured) = api( + responseBody = """ + {"api_version":1,"revision":4,"contract_etag":"\"abc123\"", + "definition_count":41, + "scopes":["account","profile","profile_device","profile_library","profile_series"], + "supports_batched_effective":true,"supports_idempotent_writes":true} + """.trimIndent(), + ) + + val result = api.getContractCapabilities() + + assertEquals("/api/v1/settings/contract/capabilities", captured.path) + assertIs(result) + assertEquals(1, result.capabilities.apiVersion) + assertEquals(4, result.capabilities.revision) + assertEquals("\"abc123\"", result.capabilities.contractEtag) + assertEquals(41, result.capabilities.definitionCount) + assertEquals(5, result.capabilities.scopes.size) + assertTrue(result.capabilities.supportsBatchedEffective) + assertTrue(result.capabilities.supportsIdempotentWrites) + } + + @Test + fun `getContractCapabilities maps a routeless 404 to ServerUpgradeRequired`() = runTest { + // An old server has no /settings/contract routes: the router answers a + // plain-text 404, not the JSON error shape. + val (api, _) = api( + status = HttpStatusCode.NotFound, + responseBody = "404 page not found", + responseContentType = "text/plain", + ) + + assertIs(api.getContractCapabilities()) + } + + @Test + fun `getContractCapabilities keeps a profile-not-found 404 off the upgrade path`() = runTest { + // The route sits behind the viewer-access middleware, which answers + // this exact body when the X-Profile-Id we send names a profile the + // household deleted from another device. The server is current; the + // fix is picking a profile, so the upgrade notice must not appear. + val (api, _) = api( + status = HttpStatusCode.NotFound, + responseBody = """{"error":"not_found","message":"Profile not found"}""", + ) + + val result = api.getContractCapabilities() + + assertIs(result) + assertEquals(404, result.code) + assertEquals("not_found", result.error) + } + + @Test + fun `getContractCapabilities keeps other failures on the generic error path`() = runTest { + val (api, _) = api( + status = HttpStatusCode.InternalServerError, + responseBody = """{"error":"internal_error","message":"Failed to read the settings contract"}""", + ) + + val result = api.getContractCapabilities() + + assertIs(result) + assertEquals(500, result.code) + assertEquals("internal_error", result.error) + } + + // ---- batched effective resolution ---- + + @Test + fun `getEffectiveValues sends csv params and parses constrained values`() = runTest { + val (api, captured) = api( + responseBody = """ + {"settings":[ + {"key":"playback.auto_play_next","value":true,"source":"default", + "suggested_values":["en","pt-BR"]}, + {"key":"playback.preferred_quality","value":"1080p","source":"profile_device", + "stored_value":"2160p","constrained":true,"constraint_kind":"ceiling", + "scope":"profile_device","profile_id":"p1","device_id":"d1"} + ], + "revision":4} + """.trimIndent(), + ) + + val result = api.getEffectiveValues( + keys = listOf("playback.auto_play_next", "playback.preferred_quality"), + libraryIds = listOf(3, 7), + seriesIds = listOf("s1", "s2"), + ) + + assertEquals("/api/v1/settings/values/effective", captured.path) + assertEquals("playback.auto_play_next,playback.preferred_quality", captured.query["keys"]) + assertEquals("3,7", captured.query["library_ids"]) + assertEquals("s1,s2", captured.query["series_ids"]) + + assertIs>(result) + val response = (result as ApiResult.Success).data + assertEquals(4, response.revision) + + val default = response.settings[0] + assertEquals(EffectiveSettingValue.SOURCE_DEFAULT, default.source) + assertEquals(true, default.value.jsonPrimitive.content.toBoolean()) + assertFalse(default.constrained) + assertNull(default.scope) + assertEquals(listOf("en", "pt-BR"), default.suggestedValues) + + val capped = response.settings[1] + assertEquals("1080p", capped.value.jsonPrimitive.content) + assertEquals("2160p", capped.storedValue?.jsonPrimitive?.content) + assertTrue(capped.constrained) + assertEquals("ceiling", capped.constraintKind) + assertEquals("profile_device", capped.scope) + assertEquals("d1", capped.deviceId) + } + + @Test + fun `getEffectiveValues omits empty params so the server resolves every key`() = runTest { + val (api, captured) = api(responseBody = """{"settings":[],"revision":1}""") + + api.getEffectiveValues() + + assertTrue(captured.query.isEmpty()) + } + + // ---- writes ---- + + @Test + fun `putValue sends the scope identity mutation id and typed body`() = runTest { + val (api, captured) = api( + responseBody = """ + {"key":"playback.preferred_quality","scope":"profile_library", + "profile_id":"p1","library_id":7,"value":"1080p", + "revision":12,"updated_at":"2026-07-28T00:00:00Z"} + """.trimIndent(), + ) + + val result = api.putValue( + key = "playback.preferred_quality", + scope = SettingScopeIdentity.profileLibrary(7), + value = JsonPrimitive("1080p"), + mutationId = "mut-1", + ) + + assertEquals(HttpMethod.Put, captured.method) + assertEquals("/api/v1/settings/values/playback.preferred_quality", captured.path) + assertEquals("profile_library", captured.query["scope"]) + assertEquals("7", captured.query["library_id"]) + assertEquals("mut-1", captured.headers["X-Prairie-Mutation-Id"]) + assertEquals("""{"value":"1080p"}""", captured.body) + + assertIs>(result) + val receipt = (result as ApiResult.Success).data + assertEquals("profile_library", receipt.scope) + assertEquals(7, receipt.libraryId) + assertEquals(12L, receipt.revision) + assertEquals("2026-07-28T00:00:00Z", receipt.updatedAt) + } + + @Test + fun `putValue round-trips an object value`() = runTest { + val (api, captured) = api( + responseBody = """ + {"key":"playback.subtitle_appearance","scope":"profile", + "profile_id":"p1","value":{"size":"large","edge":"drop_shadow"}} + """.trimIndent(), + ) + + val result = api.putValue( + key = "playback.subtitle_appearance", + scope = SettingScopeIdentity.profile(), + value = buildJsonObject { + put("size", "large") + put("edge", "drop_shadow") + }, + mutationId = "mut-2", + ) + + assertEquals("""{"value":{"size":"large","edge":"drop_shadow"}}""", captured.body) + assertIs>(result) + val receipt = (result as ApiResult.Success).data + assertEquals("large", receipt.value.jsonObject["size"]?.jsonPrimitive?.content) + // A replayed receipt omits revision/updated_at; defaults must hold. + assertEquals(0L, receipt.revision) + assertNull(receipt.updatedAt) + } + + @Test + fun `putValue surfaces a mutation id conflict as a typed error`() = runTest { + val (api, _) = api( + status = HttpStatusCode.Conflict, + responseBody = """{"error":"mutation_id_conflict","message":"This mutation id was used for a different write"}""", + ) + + val result = api.putValue( + key = "playback.preferred_quality", + scope = SettingScopeIdentity.profile(), + value = JsonPrimitive("720p"), + mutationId = "mut-reused", + ) + + assertIs(result) + assertEquals(409, result.code) + assertEquals("mutation_id_conflict", result.error) + } + + @Test + fun `putValue lets an explicit profile id override the session header`() = runTest { + val (api, captured) = api(responseBody = """{"key":"k","scope":"profile","value":true}""") + + api.putValue( + key = "playback.auto_play_next", + scope = SettingScopeIdentity.profile(), + value = JsonPrimitive(true), + mutationId = "mut-3", + profileId = "child-profile", + ) + + assertEquals("child-profile", captured.headers["X-Profile-Id"]) + } + + // ---- deletes ---- + + @Test + fun `deleteValue sends the scope identity and maps 204 to success`() = runTest { + val (api, captured) = api(status = HttpStatusCode.NoContent, responseBody = "") + + val result = api.deleteValue( + key = "playback.subtitle_language", + scope = SettingScopeIdentity.profileSeries("series-9"), + ) + + assertEquals(HttpMethod.Delete, captured.method) + assertEquals("/api/v1/settings/values/playback.subtitle_language", captured.path) + assertEquals("profile_series", captured.query["scope"]) + assertEquals("series-9", captured.query["series_id"]) + assertIs>(result) + } + + @Test + fun `deleteValue reports nothing-set-here as a typed 404`() = runTest { + val (api, _) = api( + status = HttpStatusCode.NotFound, + responseBody = """{"error":"not_found","message":"No value is set at this scope"}""", + ) + + val result = api.deleteValue( + key = "playback.subtitle_language", + scope = SettingScopeIdentity.account(), + ) + + assertIs(result) + assertEquals(404, result.code) + assertEquals("not_found", result.error) + } + + // ---- mutation ids ---- + + @Test + fun `newSettingMutationId yields distinct non-blank ids`() { + val first = newSettingMutationId() + val second = newSettingMutationId() + assertTrue(first.isNotBlank()) + assertTrue(second.isNotBlank()) + assertTrue(first != second) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/CatalogRepositoryDetailCacheTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/CatalogRepositoryDetailCacheTest.kt index b15d06808..09a5e484f 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/CatalogRepositoryDetailCacheTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/CatalogRepositoryDetailCacheTest.kt @@ -3,9 +3,13 @@ package org.prairieserver.prairie.repository import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.model.catalog.SeasonsResponse import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionKind +import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.network.PrairieJson import org.prairieserver.prairie.network.api.CatalogApi import org.prairieserver.prairie.repository.port.CatalogCachePort +import org.prairieserver.prairie.repository.port.CatalogCacheWriteLease import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond @@ -15,6 +19,8 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -25,9 +31,23 @@ class CatalogRepositoryDetailCacheTest { private class FakeCache( val preset: ItemDetail? = null, val seasonsPreset: SeasonsResponse? = null, + val identityTransitions: IdentityTransitionBarrier? = null, + val beforeItemCache: suspend () -> Unit = {}, ) : CatalogCachePort { var cachedId: String? = null - override suspend fun cacheItemDetail(contentId: String, detail: ItemDetail) { cachedId = contentId } + override suspend fun cacheItemDetail( + contentId: String, + detail: ItemDetail, + lease: CatalogCacheWriteLease, + ) { + beforeItemCache() + if ( + identityTransitions == null || + lease.identityGeneration == identityTransitions.generation.value + ) { + cachedId = contentId + } + } override suspend fun getCachedItemDetail(contentId: String): ItemDetail? = preset override suspend fun getCachedSeasons(seriesId: String): SeasonsResponse? = seasonsPreset } @@ -74,6 +94,22 @@ class CatalogRepositoryDetailCacheTest { assertEquals("Cached", detail?.title) } + @Test + fun prefetchUsesCachedDetailWithoutNetwork() = runTest { + val cache = FakeCache(preset = ItemDetail(contentId = "c1", type = "movie", title = "Cached")) + val result = repoThatFailsOnNetwork(cache).getItemDetailForPrefetch("c1") + assertEquals("Cached", (result as ApiResult.Success).data.title) + } + + @Test + fun prefetchFetchesAndCachesWhenDetailIsAbsent() = runTest { + val cache = FakeCache() + val result = repo(HttpStatusCode.OK, """{"content_id":"c2","type":"movie","title":"Fresh"}""", cache) + .getItemDetailForPrefetch("c2") + assertEquals("Fresh", (result as ApiResult.Success).data.title) + assertEquals("c2", cache.cachedId) + } + @Test fun doesNotServeCacheOn4xx() = runTest { val cache = FakeCache(preset = ItemDetail(contentId = "c1", type = "movie", title = "Cached")) @@ -87,4 +123,68 @@ class CatalogRepositoryDetailCacheTest { val result = repo(HttpStatusCode.ServiceUnavailable, "{}", cache).getSeasons("series-1") assertTrue(result is ApiResult.Success) } + + @Test + fun detailResponseStartedBeforeProfileSwitchIsNotCachedForNewProfile() = runTest { + val requestEntered = CompletableDeferred() + val releaseResponse = CompletableDeferred() + val client = HttpClient( + MockEngine { + requestEntered.complete(Unit) + releaseResponse.await() + respond( + """{"content_id":"c1","type":"movie","title":"Profile A"}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + val cache = FakeCache() + val identityTransitions = DefaultIdentityTransitionBarrier() + val repository = CatalogRepository( + catalogApi = CatalogApi(client), + catalogCache = cache, + identityTransitions = identityTransitions, + ) + + val oldProfileRequest = async { repository.getItemDetail("c1") } + requestEntered.await() + identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { } + releaseResponse.complete(Unit) + + assertTrue(oldProfileRequest.await() is ApiResult.Success) + assertEquals(null, cache.cachedId) + } + + @Test + fun profileSwitchBetweenRepositoryGuardAndCacheWriteDoesNotCacheOldDetail() = runTest { + val identityTransitions = DefaultIdentityTransitionBarrier() + val cache = FakeCache( + identityTransitions = identityTransitions, + beforeItemCache = { + identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { } + }, + ) + val client = HttpClient( + MockEngine { + respond( + """{"content_id":"c1","type":"movie","title":"Profile A"}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + val repository = CatalogRepository( + catalogApi = CatalogApi(client), + catalogCache = cache, + identityTransitions = identityTransitions, + ) + + assertTrue(repository.getItemDetail("c1") is ApiResult.Success) + assertEquals(null, cache.cachedId) + } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/PersonalDataRepositoryCacheTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/PersonalDataRepositoryCacheTest.kt new file mode 100644 index 000000000..ece4a7aee --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/PersonalDataRepositoryCacheTest.kt @@ -0,0 +1,68 @@ +package org.prairieserver.prairie.repository + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.personal.UserLibrary +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionKind +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.api.PersonalDataApi +import org.prairieserver.prairie.repository.port.CatalogCachePort +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PersonalDataRepositoryCacheTest { + + private class FakeCache : CatalogCachePort { + var cachedLibraries: List? = null + + override suspend fun cacheLibraries(libraries: List) { + cachedLibraries = libraries + } + } + + @Test + fun librariesResponseStartedBeforeProfileSwitchIsNotCachedForNewProfile() = runTest { + val requestEntered = CompletableDeferred() + val releaseResponse = CompletableDeferred() + val client = HttpClient( + MockEngine { + requestEntered.complete(Unit) + releaseResponse.await() + respond( + """[{"id":1,"name":"Profile A","type":"movie"}]""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + val cache = FakeCache() + val identityTransitions = DefaultIdentityTransitionBarrier() + val repository = PersonalDataRepository( + personalDataApi = PersonalDataApi(client), + catalogCache = cache, + identityTransitions = identityTransitions, + ) + + val oldProfileRequest = async { repository.listUserLibraries() } + requestEntered.await() + identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { } + releaseResponse.complete(Unit) + + assertTrue(oldProfileRequest.await() is ApiResult.Success) + assertEquals(null, cache.cachedLibraries) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/SectionRepositoryCacheTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/SectionRepositoryCacheTest.kt index 426c7bd19..e25ac9fb6 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/SectionRepositoryCacheTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/SectionRepositoryCacheTest.kt @@ -2,6 +2,9 @@ package org.prairieserver.prairie.repository import org.prairieserver.prairie.model.section.ResolvedSection import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionKind import org.prairieserver.prairie.network.PrairieJson import org.prairieserver.prairie.network.api.SectionApi import org.prairieserver.prairie.repository.port.CatalogCachePort @@ -13,7 +16,15 @@ import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -42,6 +53,36 @@ class SectionRepositoryCacheTest { return SectionRepository(SectionApi(client), cache) } + private fun gatedRepository( + homeRequestDispatcher: CoroutineDispatcher, + requestEntered: CompletableDeferred, + releaseResponse: CompletableDeferred, + body: () -> String, + onRequest: () -> Unit = {}, + identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), + ): SectionRepository { + val client = HttpClient( + MockEngine { + onRequest() + val responseBody = body() + requestEntered.complete(Unit) + releaseResponse.await() + respond( + responseBody, + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + return SectionRepository( + sectionApi = SectionApi(client), + identityTransitions = identityTransitions, + homeRequestDispatcher = homeRequestDispatcher, + ) + } + private fun section(id: String) = ResolvedSection(id = id, sectionType = id, title = id) @Test @@ -68,4 +109,149 @@ class SectionRepositoryCacheTest { val result = repo(HttpStatusCode.NotFound, "{}", cache).getLibrarySections(7) assertTrue(result is ApiResult.Error) } + + @Test + fun concurrentHomeRequestsShareOneAggregateCall() = runTest { + var calls = 0 + val entered = CompletableDeferred() + val release = CompletableDeferred() + val repository = gatedRepository( + homeRequestDispatcher = StandardTestDispatcher(testScheduler), + requestEntered = entered, + releaseResponse = release, + body = { """{"sections":[]}""" }, + onRequest = { calls += 1 }, + ) + + val requests = listOf( + async { repository.getHomeSections() }, + async { repository.getHomeSections() }, + ) + entered.await() + repeat(10) { yield() } + release.complete(Unit) + requests.awaitAll() + + assertEquals(1, calls) + } + + @Test + fun concurrentHomeItemRequestsShareOneCallPerSection() = runTest { + var calls = 0 + val entered = CompletableDeferred() + val release = CompletableDeferred() + val repository = gatedRepository( + homeRequestDispatcher = StandardTestDispatcher(testScheduler), + requestEntered = entered, + releaseResponse = release, + body = { """{"items":[],"total":0}""" }, + onRequest = { calls += 1 }, + ) + + val requests = listOf( + async { repository.getHomeSectionItems("same") }, + async { repository.getHomeSectionItems("same") }, + ) + entered.await() + repeat(10) { yield() } + release.complete(Unit) + requests.awaitAll() + + assertEquals(1, calls) + } + + @Test + fun cancelingFirstHomeCallerDoesNotCancelSharedRequestOrPoisonNextCall() = runTest { + var calls = 0 + val entered = CompletableDeferred() + val release = CompletableDeferred() + val repository = gatedRepository( + homeRequestDispatcher = StandardTestDispatcher(testScheduler), + requestEntered = entered, + releaseResponse = release, + body = { """{"sections":[]}""" }, + onRequest = { calls += 1 }, + ) + + val firstCaller = launch { repository.getHomeSections() } + entered.await() + val survivingCaller = async { repository.getHomeSections() } + repeat(10) { yield() } + firstCaller.cancelAndJoin() + release.complete(Unit) + + assertTrue(survivingCaller.await() is ApiResult.Success) + assertTrue(repository.getHomeSections() is ApiResult.Success) + assertEquals(2, calls) + } + + @Test + fun homeRequestStartedBeforeProfileSwitchDoesNotServeOldProfileSectionsToNewProfile() = runTest { + var calls = 0 + val oldRequestEntered = CompletableDeferred() + val releaseOldRequest = CompletableDeferred() + val identityTransitions = DefaultIdentityTransitionBarrier() + val repository = gatedRepository( + homeRequestDispatcher = StandardTestDispatcher(testScheduler), + requestEntered = oldRequestEntered, + releaseResponse = releaseOldRequest, + body = { + if (calls == 1) { + """{"sections":[{"id":"old","section_type":"old","title":"Old"}]}""" + } else { + """{"sections":[{"id":"new","section_type":"new","title":"New"}]}""" + } + }, + onRequest = { calls += 1 }, + identityTransitions = identityTransitions, + ) + + val oldProfileRequest = async { repository.getHomeSections() } + oldRequestEntered.await() + identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { } + val newProfileRequest = async { repository.getHomeSections() } + + releaseOldRequest.complete(Unit) + val oldProfileResult = oldProfileRequest.await() + val newProfileResult = newProfileRequest.await() + + assertEquals("Old", (oldProfileResult as ApiResult.Success).data.sections.single().title) + assertEquals("New", (newProfileResult as ApiResult.Success).data.sections.single().title) + assertEquals(2, calls) + } + + @Test + fun librarySectionsStartedBeforeProfileSwitchAreNotCachedForNewProfile() = runTest { + val requestEntered = CompletableDeferred() + val releaseResponse = CompletableDeferred() + val client = HttpClient( + MockEngine { + requestEntered.complete(Unit) + releaseResponse.await() + respond( + """{"sections":[{"id":"old","section_type":"old","title":"Profile A"}]}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + val cache = FakeCache(preset = null) + val identityTransitions = DefaultIdentityTransitionBarrier() + val repository = SectionRepository( + sectionApi = SectionApi(client), + catalogCache = cache, + identityTransitions = identityTransitions, + ) + + val oldProfileRequest = async { repository.getLibrarySections(7) } + requestEntered.await() + identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { } + releaseResponse.complete(Unit) + + assertTrue(oldProfileRequest.await() is ApiResult.Success) + assertEquals(null, cache.cachedFor) + assertEquals(null, cache.cachedSections) + } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepositoryTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepositoryTest.kt index 610c343ca..e51beac9e 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepositoryTest.kt @@ -3,8 +3,10 @@ package org.prairieserver.prairie.repository import org.prairieserver.prairie.model.watchtogether.AddSuggestionRequest import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.MemberRole import org.prairieserver.prairie.model.watchtogether.PromoteSuggestionRequest import org.prairieserver.prairie.model.watchtogether.RoomResponse +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode import org.prairieserver.prairie.model.watchtogether.RoomSnapshot import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest import org.prairieserver.prairie.model.watchtogether.Suggestion @@ -58,6 +60,11 @@ class WatchTogetherRepositoryTest { RoomResponse(RoomSnapshot(roomId = "room-1", code = "ABCD1234"), "jwt-room"), ), ) : WatchTogetherApi { + var createCalls = 0 + var addSuggestionCalls = 0 + var voteCalls = 0 + var promoteCalls = 0 + var closeCalls = 0 var lastRoomToken: String? = null var lastRoomId: String? = null var lastSelection: SetSelectionRequest? = null @@ -69,6 +76,7 @@ class WatchTogetherRepositoryTest { var listSuggestionsResult: CompletableDeferred>? = null var listSuggestionsCalls = 0 override suspend fun createRoom(request: CreateRoomRequest, scope: AuthScopeSnapshot): ApiResult { + createCalls++ lastAuthScope = scope return createResult?.await() ?: createResponse } @@ -99,6 +107,7 @@ class WatchTogetherRepositoryTest { roomToken: String, scope: AuthScopeSnapshot, ): ApiResult { + closeCalls++ lastRoomToken = roomToken lastAuthScope = scope return ApiResult.Success(Unit) @@ -114,7 +123,12 @@ class WatchTogetherRepositoryTest { roomToken: String, request: AddSuggestionRequest, scope: AuthScopeSnapshot, - ) = ApiResult.Success(SuggestionsResponse()).also { lastRoomToken = roomToken; lastAuthScope = scope } + ): ApiResult { + addSuggestionCalls++ + lastRoomToken = roomToken + lastAuthScope = scope + return ApiResult.Success(SuggestionsResponse()) + } override suspend fun deleteSuggestion( roomId: String, roomToken: String, @@ -126,7 +140,12 @@ class WatchTogetherRepositoryTest { roomToken: String, suggestionId: String, scope: AuthScopeSnapshot, - ) = ApiResult.Success(SuggestionsResponse()).also { lastRoomToken = roomToken; lastAuthScope = scope } + ): ApiResult { + voteCalls++ + lastRoomToken = roomToken + lastAuthScope = scope + return ApiResult.Success(SuggestionsResponse()) + } override suspend fun unvote( roomId: String, roomToken: String, @@ -138,7 +157,12 @@ class WatchTogetherRepositoryTest { roomToken: String, request: PromoteSuggestionRequest, scope: AuthScopeSnapshot, - ) = createResponse.also { lastRoomToken = roomToken; lastAuthScope = scope } + ): ApiResult { + promoteCalls++ + lastRoomToken = roomToken + lastAuthScope = scope + return createResponse + } } private class FakeRealtime( @@ -247,6 +271,46 @@ class WatchTogetherRepositoryTest { assertEquals("tt-9", api.lastSelection?.contentId) } + @Test + fun `empty vote room owner keeps existing suggestion vote override and close authority`() = runTest { + val api = FakeApi( + createResponse = ApiResult.Success( + RoomResponse( + room = RoomSnapshot( + roomId = "room-1", + selectionMode = RoomSelectionMode.Vote, + selfRole = MemberRole.Host, + selfCanManageRoom = true, + ), + roomAccessToken = "room-token", + ), + ), + ) + val repository = WatchTogetherRepository( + api = api, + authScopeProvider = { scopeA }, + ) + + repository.createRoom(CreateRoomRequest(selectionMode = RoomSelectionMode.Vote.wire)) + repository.addSuggestion( + AddSuggestionRequest( + contentId = "movie-1", + contentType = "movie", + title = "Movie One", + ), + ) + repository.vote("suggestion-1") + repository.promoteSuggestion(PromoteSuggestionRequest("suggestion-1")) + repository.closeRoom() + + assertEquals(1, api.createCalls) + assertEquals(1, api.addSuggestionCalls) + assertEquals(1, api.voteCalls) + assertEquals(1, api.promoteCalls) + assertEquals(1, api.closeCalls) + assertEquals("room-token", api.lastRoomToken) + } + @Test fun `join hydrates suggestions that predate the websocket connection`() = runTest { val api = FakeApi().apply { @@ -775,6 +839,84 @@ class WatchTogetherRepositoryTest { // ---- suggestions fold + voted_by_me re-merge ------------------------------ + @Test + fun `opened refreshes suggestions after reconnect without refreshing after room closed`() = runTest { + val api = FakeApi().apply { + listSuggestionsResponse = ApiResult.Success( + SuggestionsResponse(suggestions = listOf(suggestion("before-drop"))), + ) + } + val realtime = FakeRealtime() + val repository = repo(api = api, realtime = realtime) + repository.createRoom(CreateRoomRequest()) + val connection = launch { repository.connect("room-1") } + runCurrent() + + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + assertEquals(1, api.listSuggestionsCalls) + assertEquals(listOf("before-drop"), repository.suggestions.value.map { it.id }) + + realtime.events.emit(RoomRealtimeEvent.TransportTerminated()) + runCurrent() + api.listSuggestionsResponse = ApiResult.Success( + SuggestionsResponse( + suggestions = listOf( + suggestion("before-drop"), + suggestion("missed-during-drop"), + ), + ), + ) + advanceTimeBy(WatchTogetherRepository.BACKOFF_MS.first()) + runCurrent() + assertEquals(2, realtime.connectCount) + + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + assertEquals(2, api.listSuggestionsCalls) + assertEquals( + listOf("before-drop", "missed-during-drop"), + repository.suggestions.value.map { it.id }, + ) + + realtime.events.emit(RoomRealtimeEvent.Closed("host_left")) + advanceUntilIdle() + assertEquals(2, realtime.connectCount) + assertEquals(2, api.listSuggestionsCalls) + assertTrue(connection.isCompleted || connection.isCancelled) + } + + @Test + fun `rest refresh replaces authoritative local vote set`() = runTest { + val api = FakeApi().apply { + listSuggestionsResponse = ApiResult.Success( + SuggestionsResponse( + suggestions = listOf( + suggestion("removed-vote", votedByMe = true), + suggestion("preserved-vote", votedByMe = true), + ), + ), + ) + } + val repository = repo(api = api) + repository.createRoom(CreateRoomRequest()) + + repository.refreshSuggestions() + api.listSuggestionsResponse = ApiResult.Success( + SuggestionsResponse( + suggestions = listOf( + suggestion("removed-vote", votedByMe = false), + suggestion("preserved-vote", votedByMe = true), + ), + ), + ) + repository.refreshSuggestions() + + val byId = repository.suggestions.value.associateBy { it.id } + assertFalse(byId.getValue("removed-vote").votedByMe) + assertTrue(byId.getValue("preserved-vote").votedByMe) + } + @Test fun `suggestions event re-merges voted_by_me from local vote set`() = runTest { val api = FakeApi() @@ -1050,6 +1192,43 @@ class WatchTogetherRepositoryTest { job.cancel() } + @Test + fun `attach echo retains its observed epoch until the reconnect receives a fresh snapshot`() = runTest { + val attachedSnapshot = snapshot().copy(attachedSessionId = "playback-1") + val realtime = FakeRealtime().apply { + connectBehavior = { attempt -> + if (attempt == 1) { + flow { + emit(RoomRealtimeEvent.Opened) + emit(RoomRealtimeEvent.SnapshotEvent(attachedSnapshot)) + emit(RoomRealtimeEvent.TransportTerminated()) + } + } else { + events.asSharedFlow() + } + } + } + val repository = repo(realtime = realtime) + repository.createRoom(CreateRoomRequest()) + val job = launch { repository.connect("room-1") } + runCurrent() + + assertEquals(1L, repository.roomDeliveryEcho.value?.connectionEpoch) + advanceTimeBy(500) + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + + assertEquals(2L, repository.connectionState.value.epoch) + assertEquals(1L, repository.roomDeliveryEcho.value?.connectionEpoch) + + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(attachedSnapshot)) + runCurrent() + + assertEquals(2L, repository.roomDeliveryEcho.value?.connectionEpoch) + job.cancel() + } + @Test fun `send fails before the active connection reports writable`() = runTest { val realtime = FakeRealtime() diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeSectionHydratorTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeSectionHydratorTest.kt new file mode 100644 index 000000000..93515448b --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeSectionHydratorTest.kt @@ -0,0 +1,122 @@ +package org.prairieserver.prairie.viewmodel + +import org.prairieserver.prairie.model.section.HomeSectionItemsResponse +import org.prairieserver.prairie.model.section.ResolvedSection +import org.prairieserver.prairie.model.section.SectionItem +import org.prairieserver.prairie.network.ApiResult +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class HomeSectionHydratorTest { + + @Test + fun inlineSectionsRequireNoFallbackRequests() = runTest { + var calls = 0 + + val result = hydrateHomeSections( + sections = listOf(section("inline", total = 1, items = listOf(item("a")))), + ) { + calls += 1 + error("fallback must not run") + } + + assertEquals(0, calls) + assertEquals(listOf("a"), result.sections.single().items.map { it.contentId }) + assertTrue(result.fullyResolved) + } + + @Test + fun topLevelFallbackItemsHydrateTheOriginalSection() = runTest { + val result = hydrateHomeSections(listOf(section("missing", total = 1))) { + ApiResult.Success(HomeSectionItemsResponse(items = listOf(item("b")))) + } + + assertEquals("missing", result.sections.single().id) + assertEquals(listOf("b"), result.sections.single().items.map { it.contentId }) + assertTrue(result.fullyResolved) + } + + @Test + fun nestedFallbackSectionWinsWhenItContainsItems() = runTest { + val result = hydrateHomeSections(listOf(section("missing", total = 1))) { + ApiResult.Success( + HomeSectionItemsResponse( + section = section("server", total = 1, items = listOf(item("nested"))), + items = listOf(item("top-level")), + ), + ) + } + + assertEquals("server", result.sections.single().id) + assertEquals(listOf("nested"), result.sections.single().items.map { it.contentId }) + assertTrue(result.fullyResolved) + } + + @Test + fun failedFallbackMarksSnapshotPartialAndOmitsEmptySection() = runTest { + val result = hydrateHomeSections(listOf(section("missing", total = 1))) { + ApiResult.NetworkError(IllegalStateException("offline")) + } + + assertTrue(result.sections.isEmpty()) + assertFalse(result.fullyResolved) + } + + @Test + fun fallbackHydrationNeverExceedsFourConcurrentRequests() = runTest { + val started = Channel(Channel.UNLIMITED) + val release = Channel(Channel.UNLIMITED) + var active = 0 + var maximum = 0 + val ids = (1..12).map { "section-$it" } + + val hydration = async { + hydrateHomeSections(ids.map { section(it, total = 1) }) { id -> + active += 1 + maximum = maxOf(maximum, active) + started.send(id) + release.receive() + active -= 1 + ApiResult.Success(HomeSectionItemsResponse(items = listOf(item("item-$id")))) + } + } + + val observedStarts = mutableListOf() + repeat(3) { + repeat(4) { observedStarts += started.receive() } + assertNull(withTimeoutOrNull(1) { started.receive() }) + repeat(4) { release.send(Unit) } + } + + val result = hydration.await() + assertEquals(4, maximum) + assertEquals(ids.toSet(), observedStarts.toSet()) + assertEquals(ids, result.sections.map { it.id }) + assertTrue(result.fullyResolved) + } + + private fun section( + id: String, + total: Int, + items: List = emptyList(), + ) = ResolvedSection( + id = id, + sectionType = "test", + title = id, + totalCount = total, + items = items, + ) + + private fun item(id: String) = SectionItem( + contentId = id, + type = "movie", + title = id, + ) +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModelCacheIdentityTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModelCacheIdentityTest.kt new file mode 100644 index 000000000..0f12c29ea --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModelCacheIdentityTest.kt @@ -0,0 +1,100 @@ +package org.prairieserver.prairie.viewmodel + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.prairieserver.prairie.domain.MediaActionsCoordinator +import org.prairieserver.prairie.model.section.ResolvedSection +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionKind +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.api.PersonalDataApi +import org.prairieserver.prairie.network.api.SectionApi +import org.prairieserver.prairie.repository.PersonalDataRepository +import org.prairieserver.prairie.repository.SectionRepository +import org.prairieserver.prairie.repository.port.HomeCachePort +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class HomeViewModelCacheIdentityTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun homeResponseStartedBeforeProfileSwitchIsNotCachedForNewProfile() = runTest(dispatcher) { + val requestEntered = CompletableDeferred() + val releaseResponse = CompletableDeferred() + val client = HttpClient( + MockEngine { + requestEntered.complete(Unit) + releaseResponse.await() + respond( + """{"sections":[{"id":"old","section_type":"row","title":"Profile A","items":[]}]}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + val identityTransitions = DefaultIdentityTransitionBarrier() + val cache = RecordingHomeCache() + val viewModel = HomeViewModel( + sectionRepository = SectionRepository( + sectionApi = SectionApi(client), + identityTransitions = identityTransitions, + ), + mediaActions = mediaActions(), + homeCache = cache, + identityTransitions = identityTransitions, + ) + + requestEntered.await() + identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { } + releaseResponse.complete(Unit) + viewModel.uiState.first { !it.isLoading } + + assertEquals(null, cache.sections) + } + + private class RecordingHomeCache : HomeCachePort { + var sections: List? = null + + override suspend fun cacheHome(sections: List) { + this.sections = sections + } + } + + private fun mediaActions(): MediaActionsCoordinator { + val client = HttpClient(MockEngine { error("Personal data network should not be used") }) { + install(ContentNegotiation) { json(PrairieJson) } + } + return MediaActionsCoordinator(PersonalDataRepository(PersonalDataApi(client))) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatchTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatchTest.kt index 56d4c57c9..a80b5c2b3 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatchTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatchTest.kt @@ -77,6 +77,73 @@ class RoomDeliveryLatchTest { assertFalse(latch.isAttached(key)) } + @Test + fun `session traffic waits for both attach delivery and the server echo`() { + val latch = RoomDeliveryLatch() + val key = assertNotNull(latch.keyOrNull(open1, "session-a")) + + assertFalse(latch.isServerAttached(key, echo = null)) + latch.recordAttach(key, delivered = true) + assertFalse(latch.isServerAttached(key, echo = null)) + assertFalse( + latch.isServerAttached( + key, + RoomDeliveryEcho(7, 1, "session-b"), + ), + ) + assertTrue( + latch.isServerAttached( + key, + RoomDeliveryEcho(7, 1, "session-a"), + ), + ) + + val replacementEpoch = assertNotNull(latch.keyOrNull(open2, "session-a")) + assertFalse( + latch.isServerAttached( + replacementEpoch, + RoomDeliveryEcho(7, 1, "session-a"), + ), + ) + } + + @Test + fun `server attach rejects nullable keys and stale echoes`() { + val latch = RoomDeliveryLatch() + val key = assertNotNull(latch.keyOrNull(open1, "session-a")) + val matchingEcho = RoomDeliveryEcho(7, 1, "session-a") + latch.recordAttach(key, delivered = true) + + assertFalse(latch.isServerAttached(key = null, echo = matchingEcho)) + assertFalse(latch.isServerAttached(key = key, echo = null)) + assertFalse( + latch.isServerAttached( + key = key, + echo = RoomDeliveryEcho(7, 2, "session-a"), + ), + ) + } + + @Test + fun `stale prior epoch echo cannot authorize a newly delivered reconnect attach`() { + val latch = RoomDeliveryLatch() + val firstEpoch = assertNotNull(latch.keyOrNull(open1, "session-a")) + latch.recordAttach(firstEpoch, delivered = true) + val firstEcho = RoomDeliveryEcho(7, 1, "session-a") + assertTrue(latch.isServerAttached(firstEpoch, firstEcho)) + + val replacementEpoch = assertNotNull(latch.keyOrNull(open2, "session-a")) + latch.recordAttach(replacementEpoch, delivered = true) + + assertFalse(latch.isServerAttached(replacementEpoch, firstEcho)) + assertTrue( + latch.isServerAttached( + replacementEpoch, + RoomDeliveryEcho(7, 2, "session-a"), + ), + ) + } + @Test fun `delayed command for session A cannot mutate replacement session B`() { val command = TransportCommand( diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomSessionTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomSessionTest.kt index 2a878d236..77154587f 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomSessionTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomSessionTest.kt @@ -162,6 +162,37 @@ class RoomSessionTest { assertEquals(1, repository.resetCount) } + @Test + fun `room remains adopted until explicit leave or identity transition`() = runTest { + val repository = FakeRoomSessionRepository() + val barrier = DefaultIdentityTransitionBarrier() + val session = RoomSession(repository, backgroundScope, barrier) + + session.adopt("room-a").join() + runCurrent() + assertTrue(session.isActive()) + assertEquals(0, repository.resetCount) + + barrier.changing(IdentityTransitionKind.PROFILE_SWITCH) { + assertTrue(!session.isActive()) + assertEquals(1, repository.resetCount) + } + } + + @Test + fun `sign out clears the adopted room before identity mutation`() = runTest { + val repository = FakeRoomSessionRepository() + val barrier = DefaultIdentityTransitionBarrier() + val session = RoomSession(repository, backgroundScope, barrier) + session.adopt("room-a").join() + runCurrent() + + barrier.changing(IdentityTransitionKind.SIGN_OUT) { + assertTrue(!session.isActive()) + assertEquals(1, repository.resetCount) + } + } + @Test fun `every identity transition kind resets the room before mutation`() = runTest { IdentityTransitionKind.entries.forEach { kind -> diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicyTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicyTest.kt new file mode 100644 index 000000000..d132f0474 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicyTest.kt @@ -0,0 +1,57 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class WatchTogetherEntryPolicyTest { + @Test + fun selectedGuestRoutesToPlayer() { + val room = RoomSnapshot( + roomId = "room-1", + selectedContentId = "movie-1", + selfRole = MemberRole.Guest, + memberCount = 2, + ) + assertEquals(WatchTogetherEntryTarget.Player, watchTogetherEntryTarget(room)) + } + + @Test + fun emptyRoomAndSoloHostRouteToLobby() { + assertEquals( + WatchTogetherEntryTarget.Lobby, + watchTogetherEntryTarget(RoomSnapshot(roomId = "room-1")), + ) + assertEquals( + WatchTogetherEntryTarget.Lobby, + watchTogetherEntryTarget( + RoomSnapshot( + roomId = "room-1", + selectedContentId = "movie-1", + selfRole = MemberRole.Host, + memberCount = 1, + ), + ), + ) + } + + @Test + fun onlyNonTerminalNonBlankRoomIsResumable() { + assertNull(resumableWatchTogetherRoom(null)) + assertNull(resumableWatchTogetherRoom(RoomSnapshot(roomId = ""))) + assertNull( + resumableWatchTogetherRoom( + RoomSnapshot(roomId = "room-1", phase = RoomPhase.Ended), + ), + ) + assertEquals( + "room-1", + resumableWatchTogetherRoom( + RoomSnapshot(roomId = "room-1", phase = RoomPhase.Lobby), + )?.roomId, + ) + } +} diff --git a/shared/src/commonTest/resources/settings/v1/SOURCE b/shared/src/commonTest/resources/settings/v1/SOURCE new file mode 100644 index 000000000..512c4e730 --- /dev/null +++ b/shared/src/commonTest/resources/settings/v1/SOURCE @@ -0,0 +1,25 @@ +repository=https://github.com/Silo-Server/silo-server +path=contracts/settings/v1 +manifest_revision=2 +fixture_version=1 + +Both files are byte-identical copies of the server's canonical contract; do not +hand-edit either one. Re-vendor by copying them again and updating the commits +below, then run :shared:testDebugUnitTest — SettingsConformanceTest fails when +the fixture's manifest_revision, the vendored manifest's revision, and the +generated SettingKeys.REVISION stop agreeing, which is the whole point of +carrying the pair rather than the fixture alone. + +conformance.json commit=025083159f9624269483479cc05de02a822c6cd2 +manifest.json commit=025083159f9624269483479cc05de02a822c6cd2 +copied from commit=025083159f9624269483479cc05de02a822c6cd2 + +manifest.json is vendored whole, including the maintainer `notes` the server +strips before serving /api/v1/settings/contract. Keeping it byte-identical is +what lets a re-vendor be verified with a plain diff against the server repo. + +The runner reads the manifest for the facts the generated bindings in +shared/src/commonMain/.../SettingKeys.kt do not carry: resolution_order, +default_value, value_schema.ordered plus its enum member order, and +constrained_by. Those are what the fixture's expectations actually test, so the +resolver is driven by the contract rather than by a hand-copied table. diff --git a/shared/src/commonTest/resources/settings/v1/conformance.json b/shared/src/commonTest/resources/settings/v1/conformance.json new file mode 100644 index 000000000..0545434b6 --- /dev/null +++ b/shared/src/commonTest/resources/settings/v1/conformance.json @@ -0,0 +1,577 @@ +{ + "fixture_version": 1, + "manifest_revision": 2, + "description": "Cross-platform conformance cases for settings resolution. Every case runs against the shipped manifest in this directory: definitions are referenced by key, never restated, so an expectation can only be satisfied by resolving the real contract. Each platform's resolver (Go in internal/settingsresolve, TypeScript in web/src/lib/settingsResolve.ts, Kotlin and Swift in the client repos) runs every case through a hand-written runner; a runner must fail on any fixture field it does not know, because schema drift in the fixture itself is drift. A case's constraint_bindings attach a constraint to a copy of a real definition so constraint semantics stay testable even while no shipped definition carries that constraint kind. In expected entries, constrained:true requires stored_value and constraint_kind to be present, and stored_value may be null to mean the authored value was JSON null.", + "cases": [ + { + "name": "resolution_order_series_wins", + "description": "The full ladder: with values stored at every scope a content key allows, profile_series beats profile_library, profile_device, and profile.", + "keys": ["playback.subtitle_language"], + "context": { + "profile_id": "p1", + "device_id": "d1", + "library_ids": [7], + "series_ids": ["s-101"] + }, + "stored": [ + { "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" }, + { + "key": "playback.subtitle_language", + "scope": "profile_device", + "profile_id": "p1", + "device_id": "d1", + "value": "de" + }, + { + "key": "playback.subtitle_language", + "scope": "profile_library", + "profile_id": "p1", + "library_id": 7, + "value": "fr" + }, + { + "key": "playback.subtitle_language", + "scope": "profile_series", + "profile_id": "p1", + "series_id": "s-101", + "value": "ja" + } + ], + "expected": [ + { "key": "playback.subtitle_language", "value": "ja", "source": "profile_series" } + ] + }, + { + "name": "resolution_order_library_beats_device", + "description": "Without a series row, the library row wins over the device and profile rows.", + "keys": ["playback.subtitle_language"], + "context": { + "profile_id": "p1", + "device_id": "d1", + "library_ids": [7], + "series_ids": ["s-101"] + }, + "stored": [ + { "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" }, + { + "key": "playback.subtitle_language", + "scope": "profile_device", + "profile_id": "p1", + "device_id": "d1", + "value": "de" + }, + { + "key": "playback.subtitle_language", + "scope": "profile_library", + "profile_id": "p1", + "library_id": 7, + "value": "fr" + } + ], + "expected": [ + { "key": "playback.subtitle_language", "value": "fr", "source": "profile_library" } + ] + }, + { + "name": "resolution_order_device_beats_profile", + "description": "Without content rows, the device override wins over the profile fallback even though the context names a library and a series.", + "keys": ["playback.subtitle_language"], + "context": { + "profile_id": "p1", + "device_id": "d1", + "library_ids": [7], + "series_ids": ["s-101"] + }, + "stored": [ + { "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" }, + { + "key": "playback.subtitle_language", + "scope": "profile_device", + "profile_id": "p1", + "device_id": "d1", + "value": "de" + } + ], + "expected": [ + { "key": "playback.subtitle_language", "value": "de", "source": "profile_device" } + ] + }, + { + "name": "resolution_order_profile_alone", + "description": "A profile row alone resolves at profile scope.", + "keys": ["playback.subtitle_language"], + "context": { + "profile_id": "p1", + "device_id": "d1", + "library_ids": [7], + "series_ids": ["s-101"] + }, + "stored": [ + { "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" } + ], + "expected": [{ "key": "playback.subtitle_language", "value": "en", "source": "profile" }] + }, + { + "name": "device_override_beats_profile_for_quality", + "description": "playback.preferred_quality has no content scopes; its device override wins over the profile value.", + "keys": ["playback.preferred_quality"], + "context": { "profile_id": "p1", "device_id": "d1" }, + "stored": [ + { + "key": "playback.preferred_quality", + "scope": "profile", + "profile_id": "p1", + "value": "720p" + }, + { + "key": "playback.preferred_quality", + "scope": "profile_device", + "profile_id": "p1", + "device_id": "d1", + "value": "1080p" + } + ], + "expected": [ + { "key": "playback.preferred_quality", "value": "1080p", "source": "profile_device" } + ] + }, + { + "name": "missing_device_identity_drops_device_scope", + "description": "A caller with no device identity must not see a device override; the profile row answers instead. This is the anonymous jellycompat seed: a device row leaking here hands one device's settings to every client.", + "keys": ["playback.subtitle_language"], + "context": { "profile_id": "p1" }, + "stored": [ + { "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" }, + { + "key": "playback.subtitle_language", + "scope": "profile_device", + "profile_id": "p1", + "device_id": "d1", + "value": "de" + } + ], + "expected": [{ "key": "playback.subtitle_language", "value": "en", "source": "profile" }] + }, + { + "name": "foreign_identity_rows_never_resolve", + "description": "Rows for another profile, another device, or another series must not resolve just because a batched read returned them; the answer falls to the contract default.", + "keys": ["playback.subtitle_language"], + "context": { "profile_id": "p1", "device_id": "d1", "series_ids": ["s-101"] }, + "stored": [ + { "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p2", "value": "xx" }, + { + "key": "playback.subtitle_language", + "scope": "profile_device", + "profile_id": "p1", + "device_id": "d2", + "value": "yy" + }, + { + "key": "playback.subtitle_language", + "scope": "profile_series", + "profile_id": "p1", + "series_id": "s-other", + "value": "zz" + } + ], + "expected": [{ "key": "playback.subtitle_language", "value": null, "source": "default" }] + }, + { + "name": "absent_values_resolve_to_contract_defaults", + "description": "Nothing stored resolves to each definition's default_value with source \"default\": enum, boolean, integer, and nullable language tag.", + "keys": [ + "playback.subtitle_mode", + "playback.show_forced_subtitles", + "playback.next_up_prompt_seconds", + "playback.audio_language" + ], + "context": { "profile_id": "p1", "device_id": "d1" }, + "expected": [ + { "key": "playback.subtitle_mode", "value": "auto", "source": "default" }, + { "key": "playback.show_forced_subtitles", "value": true, "source": "default" }, + { "key": "playback.next_up_prompt_seconds", "value": 30, "source": "default" }, + { "key": "playback.audio_language", "value": null, "source": "default" } + ] + }, + { + "name": "batch_resolves_each_key_independently", + "description": "One batch, three keys, three different sources: a device override, a profile value, and a default.", + "keys": [ + "playback.preferred_quality", + "playback.subtitle_mode", + "playback.audio_language" + ], + "context": { "profile_id": "p1", "device_id": "d1" }, + "stored": [ + { + "key": "playback.preferred_quality", + "scope": "profile_device", + "profile_id": "p1", + "device_id": "d1", + "value": "1080p" + }, + { "key": "playback.subtitle_mode", "scope": "profile", "profile_id": "p1", "value": "always" } + ], + "expected": [ + { "key": "playback.preferred_quality", "value": "1080p", "source": "profile_device" }, + { "key": "playback.subtitle_mode", "value": "always", "source": "profile" }, + { "key": "playback.audio_language", "value": null, "source": "default" } + ] + }, + { + "name": "ceiling_caps_stored_quality_and_reports_the_stored_value", + "description": "The manifest binds playback.preferred_quality to the max_playback_quality ceiling. A stored 2160p over a 1080p cap resolves to 1080p while the authored value survives, reported as stored_value with constrained:true.", + "keys": ["playback.preferred_quality"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "playback.preferred_quality", + "scope": "profile", + "profile_id": "p1", + "value": "2160p" + } + ], + "constraints": { "max_playback_quality": "1080p" }, + "expected": [ + { + "key": "playback.preferred_quality", + "value": "1080p", + "source": "profile", + "constrained": true, + "stored_value": "2160p", + "constraint_kind": "ceiling" + } + ] + }, + { + "name": "ceiling_leaves_quality_under_the_cap_alone", + "description": "A value at or under the cap passes through untouched and is not reported as constrained.", + "keys": ["playback.preferred_quality"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "playback.preferred_quality", + "scope": "profile", + "profile_id": "p1", + "value": "720p" + } + ], + "constraints": { "max_playback_quality": "1080p" }, + "expected": [{ "key": "playback.preferred_quality", "value": "720p", "source": "profile" }] + }, + { + "name": "ceiling_ranks_auto_below_every_cap", + "description": "The ordered enum lists \"auto\" first because it never exceeds a cap: even the lowest cap leaves it alone.", + "keys": ["playback.preferred_quality"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "playback.preferred_quality", + "scope": "profile", + "profile_id": "p1", + "value": "auto" + } + ], + "constraints": { "max_playback_quality": "480p" }, + "expected": [{ "key": "playback.preferred_quality", "value": "auto", "source": "profile" }] + }, + { + "name": "ceiling_caps_original_as_the_highest_member", + "description": "\"original\" is the uncapped source and ranks above every resolution, so any cap brings it down.", + "keys": ["playback.preferred_quality"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "playback.preferred_quality", + "scope": "profile", + "profile_id": "p1", + "value": "original" + } + ], + "constraints": { "max_playback_quality": "2160p" }, + "expected": [ + { + "key": "playback.preferred_quality", + "value": "2160p", + "source": "profile", + "constrained": true, + "stored_value": "original", + "constraint_kind": "ceiling" + } + ] + }, + { + "name": "null_bitrate_is_unbounded_and_a_ceiling_caps_it", + "description": "null on the nullable integer playback.max_bitrate_kbps means \"no cap of my own\", which is unbounded above. It has no numeric rank, so a resolver that compares it as equal lets the one value that most needs capping slip past; a ceiling must bring it down to the limit.", + "keys": ["playback.max_bitrate_kbps"], + "context": { "profile_id": "p1" }, + "stored": [ + { "key": "playback.max_bitrate_kbps", "scope": "profile", "profile_id": "p1", "value": null } + ], + "constraint_bindings": [ + { + "key": "playback.max_bitrate_kbps", + "policy_input": "max_bitrate_kbps", + "constraint": "ceiling" + } + ], + "constraints": { "max_bitrate_kbps": 8000 }, + "expected": [ + { + "key": "playback.max_bitrate_kbps", + "value": 8000, + "source": "profile", + "constrained": true, + "stored_value": null, + "constraint_kind": "ceiling" + } + ] + }, + { + "name": "default_null_bitrate_is_capped_by_a_ceiling", + "description": "The contract default for playback.max_bitrate_kbps is null, so even with nothing stored a ceiling caps the resolved default; source stays \"default\" and the null is reported as stored_value.", + "keys": ["playback.max_bitrate_kbps"], + "context": { "profile_id": "p1" }, + "constraint_bindings": [ + { + "key": "playback.max_bitrate_kbps", + "policy_input": "max_bitrate_kbps", + "constraint": "ceiling" + } + ], + "constraints": { "max_bitrate_kbps": 8000 }, + "expected": [ + { + "key": "playback.max_bitrate_kbps", + "value": 8000, + "source": "default", + "constrained": true, + "stored_value": null, + "constraint_kind": "ceiling" + } + ] + }, + { + "name": "floor_leaves_an_unbounded_bitrate_alone", + "description": "The mirror rule: unbounded already satisfies any floor, so a floor must not touch a null numeric.", + "keys": ["playback.max_bitrate_kbps"], + "context": { "profile_id": "p1" }, + "stored": [ + { "key": "playback.max_bitrate_kbps", "scope": "profile", "profile_id": "p1", "value": null } + ], + "constraint_bindings": [ + { + "key": "playback.max_bitrate_kbps", + "policy_input": "min_bitrate_kbps", + "constraint": "floor" + } + ], + "constraints": { "min_bitrate_kbps": 8000 }, + "expected": [{ "key": "playback.max_bitrate_kbps", "value": null, "source": "profile" }] + }, + { + "name": "allowlist_falls_back_when_the_default_is_outside_the_list", + "description": "With nothing stored, catalog.metadata_language resolves to its default null, which is outside the allowlist. The fallback is the first allowed member — not the definition default, which is exactly the value the policy forbids.", + "keys": ["catalog.metadata_language"], + "context": { "profile_id": "p1" }, + "constraint_bindings": [ + { + "key": "catalog.metadata_language", + "policy_input": "allowed_metadata_languages", + "constraint": "allowlist" + } + ], + "constraints": { "allowed_metadata_languages": ["en", "fr"] }, + "expected": [ + { + "key": "catalog.metadata_language", + "value": "en", + "source": "default", + "constrained": true, + "stored_value": null, + "constraint_kind": "allowlist" + } + ] + }, + { + "name": "allowlist_replaces_a_forbidden_choice", + "description": "A stored value outside the allowlist is replaced by the first allowed member, with the authored choice preserved as stored_value.", + "keys": ["catalog.metadata_language"], + "context": { "profile_id": "p1" }, + "stored": [ + { "key": "catalog.metadata_language", "scope": "profile", "profile_id": "p1", "value": "ja" } + ], + "constraint_bindings": [ + { + "key": "catalog.metadata_language", + "policy_input": "allowed_metadata_languages", + "constraint": "allowlist" + } + ], + "constraints": { "allowed_metadata_languages": ["en", "fr"] }, + "expected": [ + { + "key": "catalog.metadata_language", + "value": "en", + "source": "profile", + "constrained": true, + "stored_value": "ja", + "constraint_kind": "allowlist" + } + ] + }, + { + "name": "allowlist_passes_a_permitted_choice", + "description": "A stored value inside the allowlist passes through untouched.", + "keys": ["catalog.metadata_language"], + "context": { "profile_id": "p1" }, + "stored": [ + { "key": "catalog.metadata_language", "scope": "profile", "profile_id": "p1", "value": "fr" } + ], + "constraint_bindings": [ + { + "key": "catalog.metadata_language", + "policy_input": "allowed_metadata_languages", + "constraint": "allowlist" + } + ], + "constraints": { "allowed_metadata_languages": ["en", "fr"] }, + "expected": [{ "key": "catalog.metadata_language", "value": "fr", "source": "profile" }] + }, + { + "name": "locked_replaces_a_differing_choice", + "description": "locked is total: the policy value replaces the user's outright, and the authored choice is preserved as stored_value so it takes effect the day the lock lifts.", + "keys": ["playback.subtitle_mode"], + "context": { "profile_id": "p1" }, + "stored": [ + { "key": "playback.subtitle_mode", "scope": "profile", "profile_id": "p1", "value": "off" } + ], + "constraint_bindings": [ + { + "key": "playback.subtitle_mode", + "policy_input": "forced_subtitle_mode", + "constraint": "locked" + } + ], + "constraints": { "forced_subtitle_mode": "always" }, + "expected": [ + { + "key": "playback.subtitle_mode", + "value": "always", + "source": "profile", + "constrained": true, + "stored_value": "off", + "constraint_kind": "locked" + } + ] + }, + { + "name": "locked_leaves_an_equal_value_unconstrained", + "description": "A stored value already equal to the lock is not a narrowing: it passes through with no constrained flag, so clients do not tell the user their own choice was overridden.", + "keys": ["playback.subtitle_mode"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "playback.subtitle_mode", + "scope": "profile", + "profile_id": "p1", + "value": "always" + } + ], + "constraint_bindings": [ + { + "key": "playback.subtitle_mode", + "policy_input": "forced_subtitle_mode", + "constraint": "locked" + } + ], + "constraints": { "forced_subtitle_mode": "always" }, + "expected": [{ "key": "playback.subtitle_mode", "value": "always", "source": "profile" }] + }, + { + "name": "locked_replaces_the_contract_default", + "description": "With nothing stored, the lock replaces even the contract default: source stays \"default\" and the default is reported as stored_value, exactly like a capped default.", + "keys": ["playback.subtitle_mode"], + "context": { "profile_id": "p1" }, + "constraint_bindings": [ + { + "key": "playback.subtitle_mode", + "policy_input": "forced_subtitle_mode", + "constraint": "locked" + } + ], + "constraints": { "forced_subtitle_mode": "always" }, + "expected": [ + { + "key": "playback.subtitle_mode", + "value": "always", + "source": "default", + "constrained": true, + "stored_value": "auto", + "constraint_kind": "locked" + } + ] + }, + { + "name": "subtitle_appearance_ignores_content_scopes", + "description": "playback.subtitle_appearance resolves profile_device then profile only. With a library and a series in the context, the device row still wins — and the sparse device object replaces the profile object outright rather than merging with it.", + "keys": ["playback.subtitle_appearance"], + "context": { + "profile_id": "p1", + "device_id": "d1", + "library_ids": [7], + "series_ids": ["s-101"] + }, + "stored": [ + { + "key": "playback.subtitle_appearance", + "scope": "profile", + "profile_id": "p1", + "value": { "fontSize": "medium", "fontColor": "#ffcc00" } + }, + { + "key": "playback.subtitle_appearance", + "scope": "profile_device", + "profile_id": "p1", + "device_id": "d1", + "value": { "fontSize": "xxlarge", "position": "top" } + } + ], + "expected": [ + { + "key": "playback.subtitle_appearance", + "value": { "fontSize": "xxlarge", "position": "top" }, + "source": "profile_device" + } + ] + }, + { + "name": "subtitle_appearance_falls_to_profile_without_device", + "description": "Without a device identity the profile's appearance object answers, unmerged.", + "keys": ["playback.subtitle_appearance"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "playback.subtitle_appearance", + "scope": "profile", + "profile_id": "p1", + "value": { "fontSize": "medium", "fontColor": "#ffcc00" } + }, + { + "key": "playback.subtitle_appearance", + "scope": "profile_device", + "profile_id": "p1", + "device_id": "d1", + "value": { "fontSize": "xxlarge", "position": "top" } + } + ], + "expected": [ + { + "key": "playback.subtitle_appearance", + "value": { "fontSize": "medium", "fontColor": "#ffcc00" }, + "source": "profile" + } + ] + } + ] +} diff --git a/shared/src/commonTest/resources/settings/v1/manifest.json b/shared/src/commonTest/resources/settings/v1/manifest.json new file mode 100644 index 000000000..54fe5a08d --- /dev/null +++ b/shared/src/commonTest/resources/settings/v1/manifest.json @@ -0,0 +1,996 @@ +{ + "api_version": 1, + "revision": 2, + "option_sets": { + "playback_audio_languages": { + "type": "language_tag", + "options": [ + { "value": "ar", "introduced_in": 1 }, + { "value": "bn", "introduced_in": 1 }, + { "value": "bg", "introduced_in": 1 }, + { "value": "zh", "introduced_in": 1 }, + { "value": "hr", "introduced_in": 1 }, + { "value": "cs", "introduced_in": 1 }, + { "value": "da", "introduced_in": 1 }, + { "value": "nl", "introduced_in": 1 }, + { "value": "en", "introduced_in": 1 }, + { "value": "fi", "introduced_in": 1 }, + { "value": "fr", "introduced_in": 1 }, + { "value": "de", "introduced_in": 1 }, + { "value": "el", "introduced_in": 1 }, + { "value": "he", "introduced_in": 1 }, + { "value": "hi", "introduced_in": 1 }, + { "value": "hu", "introduced_in": 1 }, + { "value": "id", "introduced_in": 1 }, + { "value": "it", "introduced_in": 1 }, + { "value": "ja", "introduced_in": 1 }, + { "value": "ko", "introduced_in": 1 }, + { "value": "ms", "introduced_in": 1 }, + { "value": "no", "introduced_in": 1 }, + { "value": "fa", "introduced_in": 1 }, + { "value": "pl", "introduced_in": 1 }, + { "value": "pt", "introduced_in": 1 }, + { "value": "ro", "introduced_in": 1 }, + { "value": "ru", "introduced_in": 1 }, + { "value": "sk", "introduced_in": 1 }, + { "value": "sl", "introduced_in": 1 }, + { "value": "es", "introduced_in": 1 }, + { "value": "sv", "introduced_in": 1 }, + { "value": "ta", "introduced_in": 1 }, + { "value": "te", "introduced_in": 1 }, + { "value": "th", "introduced_in": 1 }, + { "value": "tr", "introduced_in": 1 }, + { "value": "uk", "introduced_in": 1 }, + { "value": "vi", "introduced_in": 1 } + ] + }, + "playback_subtitle_languages": { + "type": "language_tag", + "options": [ + { "value": "ar", "introduced_in": 1 }, + { "value": "bn", "introduced_in": 1 }, + { "value": "bg", "introduced_in": 1 }, + { "value": "zh", "introduced_in": 1 }, + { "value": "hr", "introduced_in": 1 }, + { "value": "cs", "introduced_in": 1 }, + { "value": "da", "introduced_in": 1 }, + { "value": "nl", "introduced_in": 1 }, + { "value": "en", "introduced_in": 1 }, + { "value": "fi", "introduced_in": 1 }, + { "value": "fr", "introduced_in": 1 }, + { "value": "de", "introduced_in": 1 }, + { "value": "el", "introduced_in": 1 }, + { "value": "he", "introduced_in": 1 }, + { "value": "hi", "introduced_in": 1 }, + { "value": "hu", "introduced_in": 1 }, + { "value": "id", "introduced_in": 1 }, + { "value": "it", "introduced_in": 1 }, + { "value": "ja", "introduced_in": 1 }, + { "value": "ko", "introduced_in": 1 }, + { "value": "ms", "introduced_in": 1 }, + { "value": "no", "introduced_in": 1 }, + { "value": "fa", "introduced_in": 1 }, + { "value": "pl", "introduced_in": 1 }, + { "value": "pt", "introduced_in": 1 }, + { "value": "ro", "introduced_in": 1 }, + { "value": "ru", "introduced_in": 1 }, + { "value": "sk", "introduced_in": 1 }, + { "value": "sl", "introduced_in": 1 }, + { "value": "es", "introduced_in": 1 }, + { "value": "sv", "introduced_in": 1 }, + { "value": "ta", "introduced_in": 1 }, + { "value": "te", "introduced_in": 1 }, + { "value": "th", "introduced_in": 1 }, + { "value": "tr", "introduced_in": 1 }, + { "value": "uk", "introduced_in": 1 }, + { "value": "vi", "introduced_in": 1 } + ] + }, + "catalog_metadata_languages": { + "type": "language_tag", + "options": [ + { "value": "ar", "introduced_in": 1 }, + { "value": "bn", "introduced_in": 1 }, + { "value": "bg", "introduced_in": 1 }, + { "value": "zh", "introduced_in": 1 }, + { "value": "hr", "introduced_in": 1 }, + { "value": "cs", "introduced_in": 1 }, + { "value": "da", "introduced_in": 1 }, + { "value": "nl", "introduced_in": 1 }, + { "value": "en", "introduced_in": 1 }, + { "value": "fi", "introduced_in": 1 }, + { "value": "fr", "introduced_in": 1 }, + { "value": "de", "introduced_in": 1 }, + { "value": "el", "introduced_in": 1 }, + { "value": "he", "introduced_in": 1 }, + { "value": "hi", "introduced_in": 1 }, + { "value": "hu", "introduced_in": 1 }, + { "value": "id", "introduced_in": 1 }, + { "value": "it", "introduced_in": 1 }, + { "value": "ja", "introduced_in": 1 }, + { "value": "ko", "introduced_in": 1 }, + { "value": "ms", "introduced_in": 1 }, + { "value": "no", "introduced_in": 1 }, + { "value": "fa", "introduced_in": 1 }, + { "value": "pl", "introduced_in": 1 }, + { "value": "pt", "introduced_in": 1 }, + { "value": "ro", "introduced_in": 1 }, + { "value": "ru", "introduced_in": 1 }, + { "value": "sk", "introduced_in": 1 }, + { "value": "sl", "introduced_in": 1 }, + { "value": "es", "introduced_in": 1 }, + { "value": "sv", "introduced_in": 1 }, + { "value": "ta", "introduced_in": 1 }, + { "value": "te", "introduced_in": 1 }, + { "value": "th", "introduced_in": 1 }, + { "value": "tr", "introduced_in": 1 }, + { "value": "uk", "introduced_in": 1 }, + { "value": "vi", "introduced_in": 1 } + ] + } + }, + "definitions": [ + { + "key": "playback.audio_language", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device", "profile_library", "profile_series"], + "resolution_order": [ + "profile_series", + "profile_library", + "profile_device", + "profile", + "default" + ], + "value_schema": { "type": "language_tag", "nullable": true }, + "default_value": null, + "category": "playback", + "label": "Preferred audio language", + "description": "Choose which spoken language Prairie should prefer first.", + "recommended_control": "select", + "suggested_options": "playback_audio_languages", + "unset_label": "No preference", + "notes": "Migrates user_profiles.language as the roaming fallback. Existing user_device_settings values become real overrides, and the per-series value comes from AudioPreference.audio_language. AudioPreference.audio_track_index and track_signature stay specialized: they identify a concrete track, not a default. The legacy string-only endpoint has no way to send null, so it spells \"no preference\" as the empty string and both Android and web send that to clear the choice; its validator accepts \"\" and otherwise requires a well-formed tag via settingscontract.NormalizeLanguageTag. Migration maps \"\" to no stored row, the same way playback.subtitle_mode handles it." + }, + { + "key": "playback.subtitle_language", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device", "profile_library", "profile_series"], + "resolution_order": [ + "profile_series", + "profile_library", + "profile_device", + "profile", + "default" + ], + "value_schema": { "type": "language_tag", "nullable": true }, + "default_value": null, + "category": "playback", + "label": "Preferred subtitle language", + "description": "Choose which subtitle language Prairie should prefer first.", + "recommended_control": "select", + "suggested_options": "playback_subtitle_languages", + "unset_label": "None", + "notes": "Migrates user_profiles.subtitle_language, LibraryPlaybackPreference.subtitle_language, and SubtitlePreference.subtitle_language. SubtitlePreference.subtitle_track_index, external_subtitle_path, and track_signature stay specialized." + }, + { + "key": "playback.subtitle_mode", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device", "profile_library", "profile_series"], + "resolution_order": [ + "profile_series", + "profile_library", + "profile_device", + "profile", + "default" + ], + "value_schema": { + "type": "enum", + "values": [ + { "value": "auto", "label": "Auto" }, + { "value": "always", "label": "Always on" }, + { "value": "off", "label": "Off" } + ] + }, + "default_value": "auto", + "category": "playback", + "label": "Subtitles", + "description": "When Prairie should turn subtitles on.", + "recommended_control": "select", + "notes": "The legacy empty string means unset, not a fourth mode. Migration maps \"\" to no stored row so it resolves to the next scope." + }, + { + "key": "playback.show_forced_subtitles", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device", "profile_library", "profile_series"], + "resolution_order": [ + "profile_series", + "profile_library", + "profile_device", + "profile", + "default" + ], + "value_schema": { "type": "boolean" }, + "default_value": true, + "category": "playback", + "label": "Show forced subtitles", + "description": "Show subtitles for foreign-language dialogue even when subtitles are off.", + "recommended_control": "switch", + "notes": "Default is true because that is what the server resolves today: user_profiles.show_forced_subtitles is NOT NULL DEFAULT true (migration 029) and profile creation sets it true. A false default here would silently turn forced subtitles off for every profile that never touched the toggle. The Has* companion booleans on LibraryPlaybackPreference and SubtitlePreference encode set-vs-unset at the library and series scopes, so migration writes rows there only where Has* is true. The profile column has no companion and cannot distinguish an explicit true from the column default, so migration writes a profile row only where the value is false — the value that differs from the default." + }, + { + "key": "playback.subtitle_appearance", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { "type": "object", "schema_ref": "subtitle-appearance.json" }, + "default_value": { + "fontSize": "large", + "fontFamily": "sans-serif", + "fontColor": "#ffffff", + "backgroundColor": "#000000", + "backgroundStyle": "shadow", + "backgroundOpacity": 75, + "textOutline": false, + "textOutlineColor": "#000000", + "position": "bottom" + }, + "category": "playback", + "label": "Subtitle appearance", + "description": "How subtitles are drawn during playback.", + "recommended_control": "panel", + "notes": "Renamed from the unprefixed legacy key \"subtitle_appearance\". Every other canonical key carries a domain prefix, and preserving accidental key names is an explicit non-goal of the design. The rename touches three URL paths in internal/api/router.go, the admin device-settings routes, and the key constant in every client, so it cannot land without them. Migration copies the account-level legacy fallback to every existing profile and leaves device overrides unchanged, rewriting the key on each row. The default below is the web client's; Apple defaults to a box background and Android to no background with an outline, so migration must first write each platform's own default into a row for users who never opened the panel, or their subtitles silently change appearance at cutover." + }, + { + "key": "playback.preferred_quality", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { + "type": "enum", + "ordered": true, + "values": [ + { "value": "auto", "label": "Auto" }, + { "value": "480p", "label": "480p" }, + { "value": "720p", "label": "720p" }, + { "value": "1080p", "label": "1080p" }, + { "value": "2160p", "label": "2160p / 4K" }, + { "value": "original", "label": "Original quality" } + ] + }, + "default_value": "auto", + "constrained_by": { + "policy_input": "max_playback_quality", + "constraint": "ceiling" + }, + "category": "playback", + "label": "Preferred quality", + "description": "Pick the quality Prairie should prefer.", + "recommended_control": "select", + "notes": "The resolution axis. Members are exactly the vocabulary the server already speaks: NormalizeQualityV3 in internal/playback/protocol_v3.go accepts auto, 480p, 720p, 1080p, 2160p and original and normalizes anything else to auto with a degradation warning. Transcode ladder rungs (328p, 720p-high, 1080p-8 and friends) are deliberately absent — they were never a third dimension, only a bitrate spelled into the resolution string. web/src/player/hooks/useTranscodeQuality.ts already decomposes them, defining 1080p-high as {resolution: 1080p, bitrate: 10000} and sending the two to the server separately, so the compound form never reached the wire. playback.max_bitrate_kbps is now that second axis, and clients compose the two into whatever presets they want to show. Members are listed ascending so the ceiling constraint has a defined direction; \"auto\" sorts lowest because it never exceeds a cap, and \"original\" highest because it is the uncapped source. Enforcing the ceiling needs internal/access/quality.go to learn both sentinels: qualityRank ranks neither today, so auto and original both tie at 0 with unset and a cap would let original through. Migrates user_profiles.quality_preference as the profile fallback. The legacy column is NOT NULL DEFAULT '1080p', and that default was the effective playback cap, so existing profiles receive explicit 1080p and 6000 kbps rows; newly created profiles use the contract's auto/null defaults. The account/profile max_playback_quality columns stay in internal/policy and are NOT settings." + }, + { + "key": "playback.max_bitrate_kbps", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { + "type": "integer", + "nullable": true, + "minimum": 100, + "maximum": 200000 + }, + "default_value": null, + "unit": "kbps", + "category": "playback", + "label": "Maximum bitrate", + "description": "Cap how much bandwidth playback may use. No cap means Prairie picks for the chosen resolution.", + "recommended_control": "select", + "notes": "The bitrate axis, orthogonal to playback.preferred_quality. Splitting them is what the clients were already doing: the in-player switcher sends resolution and bitrate as separate fields, and downloads (DownloadQuality in prairie-android) dropped resolution entirely and kept only a bitrate ladder. Two values rather than one compound enum means a client can offer \"1080p High\" without the server having to agree on what \"High\" means — retuning a preset is a client release, not a contract break, and it stays additive under the widening rule. null is uncapped, which is why this is nullable rather than defaulting to a large number: absent and \"as much as you like\" are the same statement, and a numeric sentinel would have to be widened every time hardware improves. The bounds are deliberately loose — 100 kbps is below any watchable stream and 200 Mbps is above any remux — because this caps a preference, not a policy; entitlement limits live in internal/policy. Migration decomposes the legacy compound values: 1080p-high becomes (1080p, 10000), 720p-medium becomes (720p, 3000), 420p becomes (480p, 720), following the bitrates in web/src/player/hooks/useTranscodeQuality.ts, so no stored preference is lost to the rejects table." + }, + { + "key": "playback.auto_skip_intro", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "category": "playback", + "label": "Auto-skip intros", + "description": "Jump past intros automatically when Prairie can detect them.", + "recommended_control": "switch" + }, + { + "key": "playback.auto_skip_credits", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "category": "playback", + "label": "Auto-skip credits", + "description": "Move through end credits automatically when a skip is available.", + "recommended_control": "switch" + }, + { + "key": "playback.auto_skip_recap", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "category": "playback", + "label": "Auto-skip recaps", + "description": "Skip \"previously on\" recaps automatically when Prairie can detect them.", + "recommended_control": "switch" + }, + { + "key": "playback.auto_play_next", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": true, + "category": "playback", + "label": "Auto-play next episode", + "description": "Continue to the next episode automatically.", + "recommended_control": "switch" + }, + { + "key": "playback.auto_play_next_preview", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "category": "playback", + "label": "Preview next episode", + "description": "Show a preview of the next episode while credits play.", + "recommended_control": "switch" + }, + { + "key": "playback.next_up_prompt_seconds", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { "type": "integer", "minimum": 0, "maximum": 120 }, + "default_value": 30, + "unit": "seconds", + "category": "playback", + "label": "Next up prompt", + "description": "How long before the end of an episode the next-up prompt appears.", + "recommended_control": "slider", + "notes": "Android currently writes player.next_up_prompt_seconds. That alias is migrated to this key and removed from production writes." + }, + { + "key": "catalog.metadata_language", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { "type": "language_tag", "nullable": true }, + "default_value": null, + "category": "catalog", + "label": "Metadata language", + "description": "Language Prairie prefers for titles, descriptions, and artwork.", + "recommended_control": "select", + "suggested_options": "catalog_metadata_languages", + "unset_label": "Library default", + "notes": "Migrates user_profiles.preferred_metadata_language; that column is NOT NULL DEFAULT '', and the empty string means unset, so migration writes a row only where it is non-empty. Deliberately carries no constrained_by. An earlier draft declared an allowlist on policy input profile_preferred_metadata_language, which is circular: internal/policy/input.go populates that field from this very column and vendor/scope.rego relays it unchanged as a preference. Policy narrows nothing here, and an allowlist bound to a scalar equal to the current value would either be a no-op or reject every change the user makes." + }, + { + "key": "player.hdr_enabled", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { "type": "boolean" }, + "default_value": true, + "category": "player", + "label": "HDR", + "description": "Allow HDR output on this device.", + "recommended_control": "switch" + }, + { + "key": "player.dolby_vision_enabled", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { "type": "boolean" }, + "default_value": true, + "category": "player", + "label": "Dolby Vision", + "description": "Allow Dolby Vision output on this device.", + "recommended_control": "switch" + }, + { + "key": "player.dv_profile7_hdr10_fallback", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "category": "player", + "label": "Dolby Vision Profile 7 fallback", + "description": "Play Profile 7 sources as HDR10 when this device cannot decode them natively.", + "recommended_control": "switch", + "notes": "Android currently defaults this to true before hydration. The contract default is false, matching the server and Apple." + }, + { + "key": "player.seek_cache_enabled", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { "type": "boolean" }, + "default_value": true, + "category": "player", + "label": "Seek cache", + "description": "Keep recently played segments buffered for faster seeking.", + "recommended_control": "switch" + }, + { + "key": "player.match_frame_rate", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "platforms": ["android", "android_tv", "tvos"], + "category": "player", + "label": "Match content frame rate", + "description": "Switch the display refresh rate to match what is playing.", + "recommended_control": "switch", + "notes": "Android keeps this device-local today: it is absent from PlaybackSettingsKeys.DeviceSettings and documented there as deliberately not synced, so it was never written to the server rather than written and rejected. Registered here because a display-matching preference belongs to the device and should follow a profile across reinstalls." + }, + { + "key": "player.playback_speed", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { "type": "number", "minimum": 0.25, "maximum": 3.0, "step": 0.05 }, + "default_value": 1.0, + "unit": "x", + "category": "player", + "label": "Playback speed", + "description": "Default playback speed on this device.", + "recommended_control": "slider", + "notes": "Range matches the server and the shipped clients: Android already clamps to 0.25..3.0 and no picker offers above 3.0. The 0.05 step is enforced by ValidateValue, not just advertised, so every client's stepper lands on values the server accepts." + }, + { + "key": "player.audio_sync_ms", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { "type": "integer", "minimum": -5000, "maximum": 5000 }, + "default_value": 0, + "unit": "milliseconds", + "category": "player", + "label": "Audio sync offset", + "description": "Shift audio earlier or later to correct lip sync on this device.", + "recommended_control": "slider" + }, + { + "key": "player.subtitle_sync_ms", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { "type": "integer", "minimum": -10000, "maximum": 10000 }, + "default_value": 0, + "unit": "milliseconds", + "category": "player", + "label": "Subtitle sync offset", + "description": "Shift subtitles earlier or later on this device.", + "recommended_control": "slider" + }, + { + "key": "player.video_gravity", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { + "type": "enum", + "values": [ + { "value": "fit", "label": "Fit" }, + { "value": "fill", "label": "Fill" }, + { "value": "stretch", "label": "Stretch" } + ] + }, + "default_value": "fit", + "category": "player", + "label": "Video sizing", + "description": "How video fills the screen on this device.", + "recommended_control": "select" + }, + { + "key": "player.orientation_mode", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { + "type": "enum", + "values": [ + { "value": "landscapeLocked", "label": "Landscape" }, + { "value": "rotateFreely", "label": "Rotate freely" } + ] + }, + "default_value": "landscapeLocked", + "platforms": ["ios", "android"], + "category": "player", + "label": "Screen orientation", + "description": "Whether the player rotates with the device.", + "recommended_control": "select" + }, + { + "key": "player.sleep_timer_default_minutes", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { "type": "integer", "minimum": 0, "maximum": 240 }, + "default_value": 30, + "unit": "minutes", + "category": "player", + "label": "Default sleep timer", + "description": "Duration the sleep timer starts on when you turn it on. 0 leaves it off.", + "recommended_control": "stepper", + "notes": "Android keeps this device-local today and clamps to 0..240; it was never written to the server rather than written and rejected. The maximum matches that clamp rather than exceeding it, and the default matches Android's shipped 30, because a manifest that disagrees with the only client implementing a setting is the drift this contract exists to remove — and a default of 0 would silently turn the preset off for everyone at cutover. Raising the maximum later is additive under the widening rule: replace the bare maximum with its history so a client can still see the 240 an older server enforces. This is the duration the timer starts on, not whether one is running: the design classes a running sleep timer as private local, so only the persisted default is registered." + }, + { + "key": "ui.theme", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { + "type": "enum", + "values": [ + { "value": "midnight-cinema", "label": "Midnight Cinema" }, + { "value": "cinema-light", "label": "Cinema Light" }, + { "value": "cobalt-studio", "label": "Cobalt Studio" }, + { "value": "oxblood-noir", "label": "Oxblood Noir" }, + { "value": "evergreen-studio", "label": "Evergreen Studio" } + ] + }, + "default_value": "midnight-cinema", + "platforms": ["web"], + "category": "appearance", + "label": "Theme", + "description": "Color theme for the Prairie interface.", + "recommended_control": "select", + "notes": "Renamed from the unregistered legacy key \"ui_theme\", which the extension bag accepted without validation. Moved from account to profile scope: appearance is per household member, and the account row is copied to every profile during migration. Carries a device override because the right theme is partly a function of the screen and the room — a light theme on a phone in daylight, a dark one on a TV at night — which is the same reasoning that gives ui.text_scale one. Note that ui.custom_theme_vars and ui.custom_css stay profile-wide, so a profile's custom styling still applies on top of a device's theme override. Adding a theme is an additive enum widening. The admin-set default theme stays in server_settings and is not a user setting. Migration must also update internal/plugins/user_theme_lookup.go, which reads this value with raw SQL bound to both the old name and the account scope (SELECT value FROM user_settings WHERE user_id = $1 AND key = 'ui_theme') and feeds the X-Prairie-Theme header on every plugin request. Left alone, that query matches nothing after the rename and every plugin UI silently falls back to its own theme, with no error to notice." + }, + { + "key": "ui.text_scale", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { + "type": "enum", + "ordered": true, + "values": [ + { "value": "default", "label": "Default" }, + { "value": "large", "label": "Large" }, + { "value": "x-large", "label": "Extra large" } + ] + }, + "default_value": "default", + "platforms": ["web"], + "category": "appearance", + "label": "Text size", + "description": "Overall interface text size.", + "recommended_control": "select", + "notes": "Renamed from the unregistered legacy key \"ui_text_scale\". Allows a device override because readable text size is partly a function of the screen you are sitting in front of." + }, + { + "key": "ui.text_weight", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { + "type": "enum", + "values": [ + { "value": "default", "label": "Default" }, + { "value": "strong", "label": "Bolder" } + ] + }, + "default_value": "default", + "platforms": ["web"], + "category": "appearance", + "label": "Text weight", + "description": "Use heavier interface text for readability.", + "recommended_control": "select", + "notes": "Renamed from the unregistered legacy key \"ui_text_weight\"." + }, + { + "key": "ui.high_contrast", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "platforms": ["web"], + "category": "appearance", + "label": "High contrast", + "description": "Increase contrast across the interface.", + "recommended_control": "switch", + "notes": "Renamed from the unregistered legacy key \"ui_high_contrast\"." + }, + { + "key": "ui.custom_theme_vars", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { + "type": "object", + "schema_ref": "theme-var-overrides.json", + "nullable": true + }, + "default_value": null, + "platforms": ["web"], + "category": "appearance", + "label": "Custom theme variables", + "description": "Per-token overrides applied on top of the selected theme.", + "recommended_control": "panel", + "notes": "Renamed from the unregistered legacy key \"ui_custom_theme_vars\", which stored arbitrary unvalidated JSON." + }, + { + "key": "ui.custom_css", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { "type": "string", "max_length": 65536, "nullable": true }, + "default_value": null, + "platforms": ["web"], + "category": "appearance", + "label": "Custom CSS", + "description": "Raw CSS applied on top of the selected theme.", + "recommended_control": "text", + "notes": "Renamed from the unregistered legacy key \"ui_custom_css\". Sanitization stays in the web client (web/src/lib/cssSanitizer.ts); the contract only bounds length. This value is per-profile and is never applied to another profile's session." + }, + { + "key": "ui.date_format", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { + "type": "enum", + "values": [ + { "value": "auto", "label": "Match device" }, + { "value": "DD/MM/YYYY" }, + { "value": "MM/DD/YYYY" }, + { "value": "YYYY-MM-DD" } + ] + }, + "default_value": "auto", + "category": "appearance", + "label": "Date format", + "description": "How dates are written across the interface.", + "recommended_control": "select", + "notes": "Moved from account to profile scope; the account row is copied to every profile during migration." + }, + { + "key": "ui.time_format", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { + "type": "enum", + "values": [ + { "value": "auto", "label": "Match device" }, + { "value": "12h", "label": "12-hour" }, + { "value": "24h", "label": "24-hour" } + ] + }, + "default_value": "auto", + "category": "appearance", + "label": "Time format", + "description": "How clock times are written across the interface.", + "recommended_control": "select", + "notes": "Moved from account to profile scope; the account row is copied to every profile during migration." + }, + { + "key": "ui.library_page_state", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { + "type": "object", + "schema_ref": "library-page-state.json", + "nullable": true + }, + "default_value": null, + "platforms": ["web"], + "category": "navigation", + "label": "Remembered library view", + "description": "Saved browse state for each library.", + "notes": "Navigation state, not a user-authored preference. Stays tied to one profile on one device and is not shown as a normal setting control." + }, + { + "key": "ui.remember_library_page_state", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { "type": "boolean" }, + "default_value": true, + "platforms": ["web"], + "category": "navigation", + "label": "Remember library view", + "description": "Return to where you left off when reopening a library.", + "recommended_control": "switch" + }, + { + "key": "search.media_scope", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { + "type": "enum", + "values": [ + { "value": "all", "label": "Everything" }, + { "value": "video", "label": "Movies and series" }, + { "value": "audiobook", "label": "Audiobooks" } + ] + }, + "default_value": "video", + "category": "search", + "label": "Search scope", + "description": "What search covers by default.", + "recommended_control": "select", + "notes": "Moved from account to profile scope; the account row is copied to every profile during migration." + }, + { + "key": "ui.card_overlays", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { + "type": "object", + "schema_ref": "card-overlays.json", + "nullable": true + }, + "default_value": null, + "platforms": ["web"], + "category": "appearance", + "label": "Poster badges", + "description": "Which badges appear on poster cards, and where.", + "notes": "Registered from the legacy unprefixed key card_overlays, which reached the server only through the unknown-key extension bag — stored as an arbitrary string with no validation. null means the user has expressed no preference, which is what lets the server-wide admin default in the overlay-config endpoint apply; writing a resolved-but-unchosen value would silently pin them. The admin default and the enabled kill switch stay in server_settings and are not user settings." + }, + { + "key": "ui.next_up_mode", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { + "type": "enum", + "values": [ + { "value": "combined", "label": "With Continue Watching" }, + { "value": "separate", "label": "Separate row" } + ] + }, + "default_value": "combined", + "category": "navigation", + "label": "Next up episodes", + "description": "Whether upcoming episodes stay with Continue Watching or get their own row.", + "recommended_control": "select", + "notes": "Registered from the legacy unprefixed key next_up_mode. The server reads it directly when assembling home sections, so it cannot be client-local; that read moves to the canonical resolver at cutover. The legacy value was untyped and absent meant combined, which is why combined is the default rather than a third \"unset\" member." + }, + { + "key": "ui.sidebar_pins", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { + "type": "object", + "schema_ref": "sidebar-pins.json", + "nullable": true + }, + "default_value": null, + "platforms": ["web"], + "category": "navigation", + "label": "Pinned sidebar items", + "description": "Sections and collections pinned into the sidebar.", + "notes": "Registered from the legacy unprefixed key sidebar_pins. Navigation state rather than an authored preference, so it has no control; it is written by the pin affordances themselves." + }, + { + "key": "ui.disabled_library_ids", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { + "type": "object", + "schema_ref": "library-id-list.json", + "nullable": true + }, + "default_value": null, + "category": "navigation", + "label": "Hidden libraries", + "description": "Libraries you have hidden from your own browsing.", + "notes": "Registered from the legacy unprefixed key disabled_library_ids. This is the user hiding a library from themselves — it is not an access control. Library visibility enforcement lives in internal/access and internal/policy, and nothing here may be read as a permission. Profile scope rather than profile_device because hiding a library is a statement about what you want to see, not about one screen." + }, + { + "key": "ui.library_order", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { + "type": "object", + "schema_ref": "library-id-list.json", + "nullable": true + }, + "default_value": null, + "category": "navigation", + "label": "Library order", + "description": "The order your libraries appear in.", + "notes": "Registered from the legacy unprefixed key library_order. Shares library-id-list.json with ui.disabled_library_ids: both are normalized by the same normalizeLibraryIDs in web/src/hooks/queries/libraries.ts, which drops non-integers and duplicates. A library id absent from the list sorts after the ones present, so a stale id for a deleted library is inert and needs no cleanup hook." + }, + { + "key": "downloads.wifi_only", + "introduced_in": 1, + "persistence": "client_local", + "allowed_scopes": ["client_local"], + "resolution_order": ["client_local", "default"], + "value_schema": { "type": "boolean" }, + "default_value": true, + "platforms": ["ios", "android"], + "category": "downloads", + "label": "Download over Wi-Fi only", + "description": "Only download while connected to Wi-Fi.", + "recommended_control": "switch", + "notes": "Contract-known local: the value governs OS-level network constraints on the device holding the files, so it does not roam. Shared semantics across Apple and Android make it contract-owned rather than private." + }, + { + "key": "downloads.keep_watched", + "introduced_in": 1, + "persistence": "client_local", + "allowed_scopes": ["client_local"], + "resolution_order": ["client_local", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "platforms": ["ios", "android"], + "category": "downloads", + "label": "Keep watched downloads", + "description": "Do not suggest reclaiming space from downloads you have finished.", + "recommended_control": "switch", + "notes": "Contract-known local. Governs on-device storage cleanup prompts." + }, + { + "key": "downloads.default_quality", + "introduced_in": 1, + "persistence": "client_local", + "allowed_scopes": ["client_local"], + "resolution_order": ["client_local", "default"], + "value_schema": { + "type": "enum", + "ordered": true, + "values": [ + { "value": "1mbps", "label": "1 Mbps" }, + { "value": "2mbps", "label": "2 Mbps" }, + { "value": "5mbps", "label": "5 Mbps" }, + { "value": "10mbps", "label": "10 Mbps" }, + { "value": "20mbps", "label": "20 Mbps" }, + { "value": "original", "label": "Original" } + ] + }, + "default_value": "original", + "platforms": ["ios", "android"], + "category": "downloads", + "label": "Download quality", + "description": "Quality preset used for new downloads.", + "recommended_control": "select", + "notes": "Contract-known local: the value is chosen on the device holding the files and is sent on each POST /downloads rather than stored server-side. Members are the DownloadQuality wire presets, ascending. Registered as client_local rather than left unregistered because it is a user-facing preference with shared semantics, and the manifest's invariant is that no production setting exists without an entry." + }, + { + "key": "subtitle.matches_device", + "introduced_in": 1, + "persistence": "client_local", + "allowed_scopes": ["client_local"], + "resolution_order": ["client_local", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "platforms": ["ios", "tvos", "macos", "android", "android_tv"], + "category": "playback", + "label": "Match device caption settings", + "description": "Use the operating system's caption style instead of Prairie's.", + "recommended_control": "switch", + "notes": "Contract-known local: reads OS accessibility settings that only exist on the device. When enabled, playback.subtitle_appearance is not applied. Apple's existing copy separating this from profile subtitle behavior is the UX baseline. A contract key names a setting; it is not a storage key. Clients keep whatever local key they already use — Android stores this at subtitle.matches_device.local, Apple at player.subtitleMatchesSystemAppearance — so adopting the contract does not reset anyone's local preferences. The same applies to downloads.wifi_only and downloads.keep_watched, which Apple stores as downloads.wifiOnly and downloads.keepWatchedDownloads." + }, + { + "key": "player.resume_rewind_seconds", + "introduced_in": 1, + "persistence": "client_local", + "allowed_scopes": ["client_local"], + "resolution_order": ["client_local", "default"], + "value_schema": { "type": "integer", "minimum": 0, "maximum": 30 }, + "default_value": 7, + "unit": "seconds", + "platforms": ["ios", "tvos", "macos", "android", "android_tv", "web"], + "category": "player", + "label": "Rewind on resume", + "description": "Skip back this far when resuming a partly watched item, to re-establish context. 0 turns it off.", + "recommended_control": "stepper", + "notes": "Contract-known local: it tunes playback feel on the device doing the playing. Registered so the name, range and default are shared rather than reinvented per platform." + }, + { + "key": "player.passout_threshold", + "introduced_in": 1, + "persistence": "client_local", + "allowed_scopes": ["client_local"], + "resolution_order": ["client_local", "default"], + "value_schema": { "type": "integer", "minimum": 0, "maximum": 20 }, + "default_value": 3, + "unit": "episodes", + "platforms": ["ios", "tvos", "macos", "android", "android_tv", "web"], + "category": "player", + "label": "Still watching prompt", + "description": "How many episodes auto-play before Prairie asks whether you are still watching. 0 never asks.", + "recommended_control": "stepper", + "notes": "Contract-known local: pass-out protection counts consecutive auto-advances in one client session, which no other device can observe." + }, + { + "key": "player.picture_in_picture_enabled", + "introduced_in": 1, + "persistence": "client_local", + "allowed_scopes": ["client_local"], + "resolution_order": ["client_local", "default"], + "value_schema": { "type": "boolean" }, + "default_value": true, + "platforms": ["ios", "macos", "android"], + "category": "player", + "label": "Picture in picture", + "description": "Keep playing in a floating window when you leave the player.", + "recommended_control": "switch", + "notes": "Contract-known local: picture-in-picture is an OS capability of the device, not a playback preference the server resolves." + }, + { + "key": "nav.show_audiobooks", + "introduced_in": 1, + "persistence": "client_local", + "allowed_scopes": ["client_local"], + "resolution_order": ["client_local", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "platforms": ["ios", "tvos", "macos", "android", "android_tv"], + "category": "nav", + "label": "Show audiobooks", + "description": "Show the Audiobooks section in navigation.", + "recommended_control": "switch", + "notes": "Contract-known local: an opt-in navigation surface, hidden by default, with existing Apple (AppNavPreferences.showAudiobooks) and Android parity. Android stores it locally at nav.show_audiobooks.local." + } + ] +}